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>
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
# Keep the build context small and, more importantly, HERMETIC: the image must
|
||||
# be built from sources only, never from whatever happens to be lying around in
|
||||
# a developer's working tree.
|
||||
|
||||
.git
|
||||
.gitea
|
||||
.gitignore
|
||||
.dockerignore
|
||||
|
||||
# Host-built artifacts. The image builds its own; copying a linux/amd64 binary
|
||||
# from a Windows checkout (or vice versa) would silently poison the image.
|
||||
dist/
|
||||
prizma
|
||||
prizma.exe
|
||||
*.test
|
||||
|
||||
# node_modules is reinstalled by `npm ci` inside the node stage — a host copy
|
||||
# carries the host's optional platform binaries (esbuild/rollup) and breaks the
|
||||
# alpine build.
|
||||
web/node_modules
|
||||
web/dist
|
||||
node_modules
|
||||
|
||||
# The SPA is copied in from the node stage; the committed placeholder would
|
||||
# otherwise shadow it if the copy order ever changed.
|
||||
internal/webui/dist
|
||||
|
||||
# Live state and secrets must never enter an image layer.
|
||||
data/
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Docs and examples do not affect the binary.
|
||||
docs/
|
||||
*.md
|
||||
!README.md
|
||||
docker-compose*.yml
|
||||
LICENSE
|
||||
|
||||
.idea/
|
||||
.vscode/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,72 @@
|
||||
# Prizma configuration — copy to .env and edit.
|
||||
#
|
||||
# cp .env.example .env
|
||||
#
|
||||
# Every key Prizma reads is listed here with its built-in default. A key you
|
||||
# leave commented out keeps that default; there is no separate config file.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Network
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Listen address, "host:port". Leave the host empty to bind every interface.
|
||||
# Put Prizma behind a TLS reverse proxy in production — it speaks plain HTTP.
|
||||
PRIZMA_ADDR=:8080
|
||||
|
||||
# Public base URL of THIS Prizma, without a trailing slash. Only used to print
|
||||
# ready-to-copy subscription links in the admin panel, so a wrong value costs
|
||||
# you nothing but wrong-looking links.
|
||||
PUBLIC_URL=https://sub.example.com
|
||||
|
||||
# Honour X-Forwarded-For / X-Real-IP when determining the client IP.
|
||||
# Turn this on ONLY behind a reverse proxy you control: with a direct-to-Internet
|
||||
# listener any client can forge the header, and the IP ends up in the client list
|
||||
# and in the synthetic-HWID fingerprint.
|
||||
TRUST_PROXY=false
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Storage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# SQLite database path. The parent directory must be writable by the process.
|
||||
# In the Docker image this is /data/prizma.db on the persistent volume.
|
||||
PRIZMA_DB=data/prizma.db
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Admin authentication
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
ADMIN_USER=admin
|
||||
|
||||
# CHANGE THIS. The default is "admin" and Prizma warns loudly at startup while
|
||||
# it stays that way — the panel holds every upstream subscription URL you own.
|
||||
ADMIN_PASSWORD=change-me-please
|
||||
|
||||
# HS256 signing key for admin session tokens. Leave empty and Prizma generates
|
||||
# one on first start and persists it in the DB `settings` table, so sessions
|
||||
# survive restarts. Set it explicitly when you run several replicas off one DB,
|
||||
# or when you want to invalidate every issued token by rotating it.
|
||||
# openssl rand -hex 32
|
||||
JWT_SECRET=
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Upstream fetching
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Seconds an upstream response is reused before Prizma refetches it.
|
||||
# This is what protects the upstream panel from your device fan-out: 40 clients
|
||||
# refreshing hourly still cost the upstream one request per CACHE_TTL.
|
||||
# A per-source override exists in the panel; 0 there means "use this value".
|
||||
CACHE_TTL=300
|
||||
|
||||
# Seconds before an upstream request is abandoned. A source that times out is
|
||||
# marked failed; inside a group it is skipped, and the group still renders from
|
||||
# the members that answered.
|
||||
UPSTREAM_TIMEOUT=20
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Logging
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# debug | info | warn | error
|
||||
LOG_LEVEL=info
|
||||
@@ -0,0 +1,18 @@
|
||||
# Normalize to LF in the repository. Without this, a Windows checkout with
|
||||
# core.autocrlf=true hands CRLF files to CI, and the runner's /bin/sh dies on
|
||||
# `\r` in ci/*.sh with the classic "not found" error.
|
||||
* text=auto eol=lf
|
||||
|
||||
# Never touched by line-ending conversion.
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.ico binary
|
||||
*.gz binary
|
||||
*.zip binary
|
||||
|
||||
# Fixtures are byte-compared by the round-trip tests: any rewrite breaks them.
|
||||
internal/subfmt/testdata/live_* -text
|
||||
|
||||
# Keep generated lockfiles and the vendored SPA placeholder out of diff noise.
|
||||
web/package-lock.json linguist-generated=true
|
||||
internal/webui/dist/index.html linguist-generated=true
|
||||
@@ -0,0 +1,235 @@
|
||||
# Prizma — cut a release on a `v*` tag.
|
||||
#
|
||||
# WHAT SHIPS
|
||||
# 1. Four self-contained binaries (linux/amd64, linux/arm64, windows/amd64,
|
||||
# darwin/arm64) with the admin SPA embedded, each also packed as a
|
||||
# .tar.gz/.zip, plus SHA256SUMS — published as a Gitea release.
|
||||
# 2. A docker image at git.qomar.pw/omar/prizma, tagged :latest, :<version>
|
||||
# and :<sha>.
|
||||
#
|
||||
# THE GATE
|
||||
# `test` is a JOB here, not a reference to test.yml, because a separate
|
||||
# workflow cannot block another one — they run side by side and a red test.yml
|
||||
# would still let the release publish. Both `binaries` and `docker` `needs:`
|
||||
# this job, so a red test leaves nothing published and nothing pushed.
|
||||
#
|
||||
# WHY curl AND NOT A RELEASE ACTION
|
||||
# ci/gitea-release.sh talks to the Gitea API with curl only. On a self-hosted
|
||||
# act_runner, fetching a marketplace action from github.com/gitea.com is the
|
||||
# flakiest part of any run, and the popular release actions target GitHub's
|
||||
# API shape. The script is idempotent: re-running a tag replaces the assets
|
||||
# rather than 409ing.
|
||||
#
|
||||
# ACTION PINS
|
||||
# actions/cache@v3.3.2 and actions/upload-artifact@v3 are the last versions
|
||||
# speaking the cache/artifact API act_runner implements. Do not bump them.
|
||||
name: release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
# Dispatch runs are dry runs: everything is built and uploaded as a workflow
|
||||
# artifact, but nothing is published or pushed (see the `if:` guards below).
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# ---------------------------------------------------------------------------
|
||||
# THE GATE
|
||||
# ---------------------------------------------------------------------------
|
||||
test:
|
||||
name: test gate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: false
|
||||
|
||||
- name: Cache Go modules + build cache
|
||||
uses: actions/cache@v3.3.2
|
||||
with:
|
||||
path: |
|
||||
~/go/pkg/mod
|
||||
~/.cache/go-build
|
||||
key: go-${{ hashFiles('go.sum') }}
|
||||
restore-keys: |
|
||||
go-
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- name: Cache web node_modules
|
||||
uses: actions/cache@v3.3.2
|
||||
with:
|
||||
path: web/node_modules
|
||||
key: npm-${{ hashFiles('web/package-lock.json') }}
|
||||
|
||||
- name: go vet
|
||||
run: go vet ./...
|
||||
|
||||
- name: go test
|
||||
run: go test ./...
|
||||
|
||||
- name: Install web dependencies
|
||||
working-directory: web
|
||||
run: npm ci
|
||||
|
||||
- name: Build the admin panel
|
||||
working-directory: web
|
||||
run: npm run build
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BINARIES + GITEA RELEASE
|
||||
# ---------------------------------------------------------------------------
|
||||
binaries:
|
||||
name: binaries + release
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# fetch-depth: 0 — a dispatch run derives its version from `git describe`,
|
||||
# which needs tags and ancestry that the default shallow checkout lacks.
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Compute version
|
||||
id: ver
|
||||
run: |
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) V="${GITHUB_REF_NAME#v}" ;;
|
||||
*) V="$(git describe --tags --always 2>/dev/null || echo dev)"; V="${V#v}" ;;
|
||||
esac
|
||||
echo "version=$V" >> "$GITHUB_OUTPUT"
|
||||
echo "[version] $V"
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: false
|
||||
|
||||
- name: Cache Go modules + build cache
|
||||
uses: actions/cache@v3.3.2
|
||||
with:
|
||||
path: |
|
||||
~/go/pkg/mod
|
||||
~/.cache/go-build
|
||||
key: go-${{ hashFiles('go.sum') }}
|
||||
restore-keys: |
|
||||
go-
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- name: Cache web node_modules
|
||||
uses: actions/cache@v3.3.2
|
||||
with:
|
||||
path: web/node_modules
|
||||
key: npm-${{ hashFiles('web/package-lock.json') }}
|
||||
|
||||
# Builds the SPA once, embeds it into internal/webui/dist, then
|
||||
# cross-compiles all four targets and writes dist/SHA256SUMS.
|
||||
- name: Build binaries
|
||||
run: bash ci/build-binaries.sh "${{ steps.ver.outputs.version }}"
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: prizma-dist
|
||||
path: dist/
|
||||
|
||||
- name: Publish Gitea release
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
env:
|
||||
# RELEASE_TOKEN when the repo defines one, otherwise the run's own
|
||||
# token. The `!= '' &&` dance is needed because an undefined secret is
|
||||
# the empty string, not null, so a bare `||` would never fall through.
|
||||
TOKEN: ${{ secrets.RELEASE_TOKEN != '' && secrets.RELEASE_TOKEN || github.token }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
NAME: Prizma ${{ github.ref_name }}
|
||||
run: |
|
||||
set -eu
|
||||
V="${{ steps.ver.outputs.version }}"
|
||||
IMG="git.qomar.pw/${GITHUB_REPOSITORY}"
|
||||
BODY="Prizma $V — a subscription panel in front of other subscription panels.
|
||||
|
||||
One upstream seat is pinned to one device by \`x-hwid\`; Prizma holds that
|
||||
HWID and hands out a link any number of devices can use.
|
||||
|
||||
── Docker ──
|
||||
docker run -d --name prizma -p 8080:8080 -v prizma-data:/data -e ADMIN_PASSWORD=change-me $IMG:$V
|
||||
|
||||
── Binary ──
|
||||
Download the archive for your platform, verify it, run it. The admin
|
||||
panel is inside the executable — there is nothing else to install.
|
||||
sha256sum -c SHA256SUMS
|
||||
tar xzf prizma_${V}_linux_amd64.tar.gz && ./prizma
|
||||
|
||||
Full documentation: README.md in this tag."
|
||||
BODY="$BODY" bash ci/gitea-release.sh dist/*
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DOCKER IMAGE
|
||||
# ---------------------------------------------------------------------------
|
||||
# Independent of `binaries`: the Dockerfile does its own node+go build, so the
|
||||
# two lanes can run in parallel and a slow cross-compile does not hold up the
|
||||
# image. Both still hang off the same test gate.
|
||||
docker:
|
||||
name: docker image
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Compute version
|
||||
id: ver
|
||||
run: |
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) V="${GITHUB_REF_NAME#v}" ;;
|
||||
*) V="$(git describe --tags --always 2>/dev/null || echo dev)"; V="${V#v}" ;;
|
||||
esac
|
||||
echo "version=$V" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Log in to the Gitea container registry
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
run: |
|
||||
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.qomar.pw -u omar --password-stdin
|
||||
|
||||
# Plain `docker build`, not buildx: act_runner's docker daemon here has no
|
||||
# buildx plugin, and a single-platform amd64 image is what this registry
|
||||
# serves. Cross-arch images are covered by the raw binaries instead.
|
||||
- name: Build image
|
||||
run: |
|
||||
set -eu
|
||||
IMG="git.qomar.pw/${GITHUB_REPOSITORY}"
|
||||
docker build \
|
||||
--build-arg VERSION="${{ steps.ver.outputs.version }}" \
|
||||
-t "$IMG:${{ steps.ver.outputs.version }}" \
|
||||
-t "$IMG:${GITHUB_SHA}" \
|
||||
-t "$IMG:latest" \
|
||||
.
|
||||
|
||||
- name: Push image
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
run: |
|
||||
set -eu
|
||||
IMG="git.qomar.pw/${GITHUB_REPOSITORY}"
|
||||
docker push "$IMG:${{ steps.ver.outputs.version }}"
|
||||
docker push "$IMG:${GITHUB_SHA}"
|
||||
docker push "$IMG:latest"
|
||||
@@ -0,0 +1,86 @@
|
||||
# Prizma — the fast feedback loop on `main` and on every PR.
|
||||
#
|
||||
# This workflow does NOT gate the release. A separate workflow cannot block
|
||||
# another one: they run side by side, and a red `test` here would happily let a
|
||||
# tag ship. The actual gate is the `test` JOB inside release.yml, which every
|
||||
# other job there `needs:`. The two run the same three commands, so they cannot
|
||||
# drift apart silently.
|
||||
#
|
||||
# Note on Gitea: once .gitea/workflows exists, .github/workflows is ignored
|
||||
# entirely — this directory is the only one that runs.
|
||||
name: test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- 'docs/**'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: test-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: go vet + go test + spa build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: false # explicit actions/cache@v3.3.2 below
|
||||
|
||||
# v3.3.2 is the LAST actions/cache release speaking the old cache API that
|
||||
# Gitea's act_runner cache server implements; v4 (and the v3.4.x backports)
|
||||
# target GitHub's new cache service and fail here. Same reason
|
||||
# upload-artifact is pinned to v3 in release.yml. With the cache server
|
||||
# disabled this degrades to a warning and the build just runs uncached.
|
||||
- name: Cache Go modules + build cache
|
||||
uses: actions/cache@v3.3.2
|
||||
with:
|
||||
path: |
|
||||
~/go/pkg/mod
|
||||
~/.cache/go-build
|
||||
key: go-${{ hashFiles('go.sum') }}
|
||||
restore-keys: |
|
||||
go-
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- name: Cache web node_modules
|
||||
uses: actions/cache@v3.3.2
|
||||
with:
|
||||
path: web/node_modules
|
||||
# Exact key only: a lockfile change MUST miss, never restore a stale tree.
|
||||
key: npm-${{ hashFiles('web/package-lock.json') }}
|
||||
|
||||
# internal/webui embeds `all:dist`, so both vet and test need something in
|
||||
# internal/webui/dist. The committed placeholder index.html is exactly that
|
||||
# — if this step fails, the placeholder was deleted by mistake.
|
||||
- name: go vet
|
||||
run: go vet ./...
|
||||
|
||||
- name: go test
|
||||
run: go test ./...
|
||||
|
||||
- name: Install web dependencies
|
||||
working-directory: web
|
||||
run: npm ci
|
||||
|
||||
# Building the SPA here is a real check: a TypeScript error or a broken
|
||||
# import fails `vite build`, and the release lane would otherwise be the
|
||||
# first place anyone found out.
|
||||
- name: Build the admin panel
|
||||
working-directory: web
|
||||
run: npm run build
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
# ---- build output ------------------------------------------------------------
|
||||
/dist/
|
||||
/prizma
|
||||
/prizma.exe
|
||||
*.test
|
||||
*.out
|
||||
|
||||
# The SPA build lands in web/dist and is then COPIED into internal/webui/dist so
|
||||
# //go:embed can pick it up. Neither copy belongs in git...
|
||||
/web/dist/
|
||||
/internal/webui/dist/*
|
||||
# ...except the committed placeholder, without which `go build` fails on a clean
|
||||
# checkout: embed refuses an empty pattern. The real build overwrites this file,
|
||||
# so `git checkout -- internal/webui/dist/index.html` after a local build.
|
||||
!/internal/webui/dist/index.html
|
||||
|
||||
# ---- node --------------------------------------------------------------------
|
||||
node_modules/
|
||||
.vite/
|
||||
npm-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# ---- runtime state -----------------------------------------------------------
|
||||
# Default PRIZMA_DB is data/prizma.db; WAL/SHM siblings come with journal_mode=WAL.
|
||||
/data/
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
|
||||
# Real secrets. Only .env.example is tracked.
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# ---- editors / OS ------------------------------------------------------------
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
# 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"]
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Prizma contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,286 @@
|
||||
# Prizma
|
||||
|
||||
**Панель поверх чужих панелей подписок.** Prizma встаёт перед Remnawave (и
|
||||
любой другой панелью), забирает на себя привязку подписки к устройству и отдаёт
|
||||
свою собственную ссылку — уже без ограничения на количество устройств.
|
||||
|
||||
Один сервер, один статический бинарник, SQLite. Веб-панель вшита внутрь
|
||||
исполняемого файла: ставить рядом нечего.
|
||||
|
||||
---
|
||||
|
||||
## Зачем это нужно
|
||||
|
||||
Апстрим-панель привязывает подписку к **одному** устройству по заголовку
|
||||
`x-hwid`: первый клиент, который пришёл за конфигом, «занимает» место, второй
|
||||
получает отказ или чужой HWID затирает первый.
|
||||
|
||||
Prizma решает это так:
|
||||
|
||||
- HWID хранится **на стороне Prizma**, отдельно для каждого источника, и
|
||||
подставляется в каждый запрос наверх. Для апстрима это всегда одно и то же
|
||||
устройство;
|
||||
- вниз, клиентам, отдаётся **своя** ссылка Prizma, которой могут пользоваться
|
||||
сколько угодно устройств;
|
||||
- всё остальное проксируется как есть: User-Agent клиента, тело ответа,
|
||||
заголовки `profile-title`, `subscription-userinfo`, `announce`,
|
||||
`profile-update-interval` и прочие. Ни одно поле не теряется;
|
||||
- заголовки апстрима, содержащие `hwid`, наружу **не отдаются** — состояние
|
||||
привязки не должно утекать к клиентам.
|
||||
|
||||
Побочный бонус: ответ апстрима кешируется (`CACHE_TTL`), поэтому сорок клиентов,
|
||||
обновляющих подписку раз в час, стоят апстриму один запрос в пять минут.
|
||||
|
||||
---
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
```sh
|
||||
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
|
||||
|
||||
```sh
|
||||
cp docker-compose.example.yml docker-compose.yml
|
||||
cp .env.example .env # обязательно поменяйте ADMIN_PASSWORD
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Состояние целиком лежит в одном SQLite-файле на томе `/data` — бэкап этого
|
||||
файла и есть бэкап всей установки.
|
||||
|
||||
### Бинарник
|
||||
|
||||
С [релизов](https://git.qomar.pw/omar/prizma/releases) скачивается **один
|
||||
файл** под linux/amd64, linux/arm64, windows/amd64 или darwin/arm64. Веб-панель
|
||||
вкомпилирована внутрь, внешних файлов нет.
|
||||
|
||||
```sh
|
||||
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`](.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.
|
||||
|
||||
```sh
|
||||
git clone https://git.qomar.pw/omar/prizma.git
|
||||
cd prizma
|
||||
|
||||
# всё сразу: SPA + бинарники под четыре платформы в dist/
|
||||
bash ci/build-binaries.sh 0.0.0-dev
|
||||
```
|
||||
|
||||
Вручную:
|
||||
|
||||
```sh
|
||||
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`.
|
||||
|
||||
Тесты:
|
||||
|
||||
```sh
|
||||
go vet ./...
|
||||
go test ./...
|
||||
```
|
||||
|
||||
CGO нигде не используется (SQLite — `modernc.org/sqlite`), поэтому
|
||||
кросс-компиляция под все четыре платформы работает с любой из них.
|
||||
|
||||
---
|
||||
|
||||
## Лицензия
|
||||
|
||||
MIT — см. [LICENSE](LICENSE).
|
||||
@@ -0,0 +1,161 @@
|
||||
#!/bin/bash
|
||||
# Build the release binaries: ONE self-contained executable per platform, with
|
||||
# the admin SPA embedded. No sidecar assets, no unpacking — you copy the file to
|
||||
# a server (or double-click the .exe) and Prizma serves its own UI.
|
||||
#
|
||||
# ci/build-binaries.sh [version]
|
||||
#
|
||||
# Env:
|
||||
# VERSION same as the positional arg; a leading "v" is stripped
|
||||
# TARGETS space-separated os/arch overrides (default: the 4 shipped ones)
|
||||
# SKIP_WEB =1 reuse an existing web/dist instead of running npm (local loop)
|
||||
# OUT output directory (default: dist)
|
||||
#
|
||||
# Output in $OUT:
|
||||
# prizma_<version>_<os>_<arch>[.exe] the bare binary
|
||||
# prizma_<version>_<os>_<arch>.tar.gz|.zip binary + README + LICENSE + .env.example
|
||||
# SHA256SUMS over everything above
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
VERSION="${1:-${VERSION:-}}"
|
||||
if [ -z "$VERSION" ]; then
|
||||
# Off-tag builds still get a meaningful, sortable version instead of "dev".
|
||||
VERSION="$(git describe --tags --always --dirty 2>/dev/null || echo dev)"
|
||||
fi
|
||||
VERSION="${VERSION#v}"
|
||||
|
||||
OUT="${OUT:-dist}"
|
||||
TARGETS="${TARGETS:-linux/amd64 linux/arm64 windows/amd64 darwin/arm64}"
|
||||
|
||||
echo "[build] prizma $VERSION -> $OUT"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. The SPA, built ONCE and embedded into every binary.
|
||||
# ---------------------------------------------------------------------------
|
||||
# internal/webui embeds `all:dist`, so the panel must be sitting in
|
||||
# internal/webui/dist BEFORE any go build runs — including the first one.
|
||||
if [ "${SKIP_WEB:-0}" = 1 ] && [ -d web/dist ]; then
|
||||
echo "[build] SKIP_WEB=1 — reusing existing web/dist"
|
||||
else
|
||||
echo "[build] building the admin panel"
|
||||
(
|
||||
cd web
|
||||
if [ -f package-lock.json ]; then npm ci; else npm install; fi
|
||||
npm run build
|
||||
)
|
||||
fi
|
||||
|
||||
[ -f web/dist/index.html ] || {
|
||||
echo "[build] ERROR: web/dist/index.html missing — the SPA build produced nothing" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
echo "[build] embedding web/dist -> internal/webui/dist"
|
||||
# Wipe rather than overlay: the repo ships a placeholder index.html to keep
|
||||
# `go build` green on a fresh checkout, and a merge would leave it (or assets
|
||||
# from a previous build) inside the binary.
|
||||
rm -rf internal/webui/dist
|
||||
mkdir -p internal/webui/dist
|
||||
cp -R web/dist/. internal/webui/dist/
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Cross-compile
|
||||
# ---------------------------------------------------------------------------
|
||||
rm -rf "$OUT"
|
||||
mkdir -p "$OUT"
|
||||
# zip is invoked from inside the staging dir, so it needs an absolute target;
|
||||
# resolving it once also makes an absolute OUT= override work.
|
||||
OUTABS="$(cd "$OUT" && pwd)"
|
||||
|
||||
STAGE="$(mktemp -d)"
|
||||
trap 'rm -rf "$STAGE"' EXIT
|
||||
|
||||
# A python3 that actually runs. On Windows `command -v python3` happily finds
|
||||
# the Microsoft Store launcher stub, which prints "Python" and exits 49 without
|
||||
# executing anything — a probe, not a lookup, is the only reliable test.
|
||||
have_python() { python3 -c 'import zipfile' >/dev/null 2>&1; }
|
||||
|
||||
zip_dir() { # zip_dir <archive> <dir> — contents of <dir>, no wrapper folder
|
||||
local archive="$1" dir="$2"
|
||||
if command -v zip >/dev/null 2>&1; then
|
||||
( cd "$dir" && zip -q -r -X "$archive" . )
|
||||
elif have_python; then
|
||||
# act_runner images do not all ship `zip`; python3 is always there. Failing
|
||||
# the whole release over a missing 200 KB package would be absurd.
|
||||
python3 - "$archive" "$dir" <<'PY'
|
||||
import os, sys, zipfile
|
||||
archive, root = sys.argv[1], sys.argv[2]
|
||||
with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as z:
|
||||
for dirpath, _, files in os.walk(root):
|
||||
for f in sorted(files):
|
||||
p = os.path.join(dirpath, f)
|
||||
z.write(p, os.path.relpath(p, root))
|
||||
PY
|
||||
else
|
||||
echo "[build] ERROR: need either \`zip\` or a working python3 to pack $archive" >&2
|
||||
exit 1
|
||||
fi
|
||||
# Both branches have been seen to "succeed" and write nothing; a release with
|
||||
# a 0-byte .zip in it is worse than a failed build.
|
||||
[ -s "$archive" ] || { echo "[build] ERROR: $archive was not written" >&2; exit 1; }
|
||||
}
|
||||
|
||||
for t in $TARGETS; do
|
||||
os="${t%/*}"; arch="${t#*/}"
|
||||
ext=""
|
||||
if [ "$os" = windows ]; then ext=".exe"; fi
|
||||
|
||||
base="prizma_${VERSION}_${os}_${arch}"
|
||||
bin="$OUT/${base}${ext}"
|
||||
|
||||
echo "[build] $os/$arch"
|
||||
# -trimpath keeps the build machine's paths out of the binary (reproducible
|
||||
# and not leaky); -s -w drops the symbol table and DWARF, roughly halving it.
|
||||
# CGO_ENABLED=0 is mandatory — it is what makes these binaries static and
|
||||
# cross-buildable at all (hence modernc.org/sqlite over mattn/go-sqlite3).
|
||||
CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" \
|
||||
go build -trimpath -buildvcs=false \
|
||||
-ldflags "-s -w -X main.version=${VERSION}" \
|
||||
-o "$bin" ./cmd/prizma
|
||||
|
||||
# --- archive: the binary under its plain name, plus what an operator needs
|
||||
# on first run. Flat, so `tar xzf` in ~/bin gives a runnable `prizma`.
|
||||
pkg="$STAGE/$base"
|
||||
rm -rf "$pkg"; mkdir -p "$pkg"
|
||||
cp "$bin" "$pkg/prizma${ext}"
|
||||
cp README.md LICENSE .env.example "$pkg/"
|
||||
|
||||
if [ "$os" = windows ]; then
|
||||
zip_dir "$OUTABS/${base}.zip" "$pkg"
|
||||
archive="$OUT/${base}.zip"
|
||||
else
|
||||
# --owner/--group=0 so the archive does not carry the CI runner's uid.
|
||||
tar -C "$pkg" -czf "$OUT/${base}.tar.gz" --owner=0 --group=0 .
|
||||
archive="$OUT/${base}.tar.gz"
|
||||
fi
|
||||
[ -s "$bin" ] && [ -s "$archive" ] || {
|
||||
echo "[build] ERROR: $os/$arch produced an empty artifact" >&2; exit 1; }
|
||||
done
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Checksums over every published artifact
|
||||
# ---------------------------------------------------------------------------
|
||||
(
|
||||
cd "$OUT"
|
||||
# Relative names, so `sha256sum -c SHA256SUMS` works from inside the download
|
||||
# directory. Sorted for a stable, diffable file.
|
||||
files=$(ls | grep -v '^SHA256SUMS$' | sort)
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
# shellcheck disable=SC2086
|
||||
sha256sum $files > SHA256SUMS
|
||||
else
|
||||
# shellcheck disable=SC2086
|
||||
shasum -a 256 $files > SHA256SUMS # macOS host
|
||||
fi
|
||||
)
|
||||
|
||||
echo "[build] artifacts:"
|
||||
ls -lh "$OUT"
|
||||
echo "[build] done: prizma $VERSION"
|
||||
@@ -0,0 +1,110 @@
|
||||
#!/bin/bash
|
||||
# Create (or refresh) a Gitea release and upload assets via the Gitea API.
|
||||
#
|
||||
# Self-contained: uses only curl (present in the act_runner image), so it needs
|
||||
# NO external marketplace action — the safest option on a self-hosted Gitea
|
||||
# act_runner where github.com/gitea.com action fetches may be unavailable.
|
||||
#
|
||||
# Idempotent: safe to re-run for the same tag. A pre-existing release (a re-run
|
||||
# of a tag build, or a rolling pointer) is reused — its clashing assets are
|
||||
# deleted and replaced — instead of aborting on a 409.
|
||||
#
|
||||
# Env:
|
||||
# SERVER Gitea base URL, e.g. https://git.qomar.pw (default: $GITHUB_SERVER_URL)
|
||||
# REPO owner/repo (default: $GITHUB_REPOSITORY)
|
||||
# TOKEN API token with contents:write (default: $GITHUB_TOKEN)
|
||||
# TAG release tag, e.g. v0.2.0 or "latest"
|
||||
# NAME release title (default: TAG)
|
||||
# BODY release notes markdown (default: "")
|
||||
# PRERELEASE true|false (default: false)
|
||||
# TARGET commit sha the tag should point at (default: $GITHUB_SHA)
|
||||
# ROLLING true => move the tag to $TARGET (delete+recreate); for `latest`
|
||||
# Args: asset files to upload.
|
||||
set -eu
|
||||
|
||||
SERVER="${SERVER:-${GITHUB_SERVER_URL:?}}"
|
||||
REPO="${REPO:-${GITHUB_REPOSITORY:?}}"
|
||||
TOKEN="${TOKEN:-${GITHUB_TOKEN:?token required (GITHUB_TOKEN or RELEASE_TOKEN)}}"
|
||||
TAG="${TAG:?tag required}"
|
||||
NAME="${NAME:-$TAG}"
|
||||
BODY="${BODY:-}"
|
||||
PRERELEASE="${PRERELEASE:-false}"
|
||||
TARGET="${TARGET:-${GITHUB_SHA:-}}"
|
||||
ROLLING="${ROLLING:-false}"
|
||||
|
||||
API="$SERVER/api/v1/repos/$REPO"
|
||||
AUTH=(-H "Authorization: token $TOKEN")
|
||||
BODYF="$(mktemp)" # last response body
|
||||
# FIRST "<key>": <number> in the body. grep -o preserves order, so for a release
|
||||
# object the top-level "id" (the release id) comes before nested author/asset ids
|
||||
# — a greedy `.*"id":` would wrongly grab the LAST id on a one-line JSON response.
|
||||
int() { grep -o "\"$1\"[[:space:]]*:[[:space:]]*[0-9]\{1,\}" "$BODYF" | head -n1 | grep -o '[0-9]\{1,\}'; }
|
||||
|
||||
# api METHOD PATH [curl-args...] -> echoes HTTP code, body in $BODYF
|
||||
api() {
|
||||
local m="$1" p="$2"; shift 2
|
||||
curl -sS -o "$BODYF" -w '%{http_code}' -X "$m" "${AUTH[@]}" "$@" "$API$p"
|
||||
}
|
||||
|
||||
echo "[release] repo=$REPO tag=$TAG prerelease=$PRERELEASE rolling=$ROLLING target=${TARGET:0:8}"
|
||||
|
||||
# --- find any existing release for this tag (by tag lookup, then by listing) --
|
||||
find_release() {
|
||||
local code
|
||||
code=$(api GET "/releases/tags/$TAG")
|
||||
if [ "$code" = 200 ]; then int id; return; fi
|
||||
# fall back to scanning the releases list (tag lookup 404s on some versions).
|
||||
# Split the array into per-release chunks and read the id of the chunk whose
|
||||
# tag_name matches — avoids a greedy match spanning objects.
|
||||
api GET "/releases?limit=50" >/dev/null || true
|
||||
tr '{' '\n' < "$BODYF" | grep -F "\"tag_name\":\"$TAG\"" \
|
||||
| grep -o '"id"[[:space:]]*:[[:space:]]*[0-9]\{1,\}' | head -n1 | grep -o '[0-9]\{1,\}'
|
||||
}
|
||||
|
||||
rid="$(find_release || true)"
|
||||
if [ -n "${rid:-}" ]; then
|
||||
echo "[release] deleting existing release id=$rid"
|
||||
api DELETE "/releases/$rid" >/dev/null || true
|
||||
fi
|
||||
|
||||
# For a rolling tag, drop the git tag so it re-points at $TARGET on recreate.
|
||||
if [ "$ROLLING" = true ]; then
|
||||
api DELETE "/tags/$TAG" >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
# --- create the release (idempotent: reuse on 409) ---------------------------
|
||||
esc_body=$(printf '%s' "$BODY" | sed 's/\\/\\\\/g; s/"/\\"/g' | awk 'BEGIN{ORS="\\n"}{print}')
|
||||
payload=$(printf '{"tag_name":"%s","target_commitish":"%s","name":"%s","body":"%s","draft":false,"prerelease":%s}' \
|
||||
"$TAG" "$TARGET" "$NAME" "$esc_body" "$PRERELEASE")
|
||||
|
||||
code=$(api POST "/releases" -H 'Content-Type: application/json' -d "$payload")
|
||||
if [ "$code" = 201 ] || [ "$code" = 200 ]; then
|
||||
rid=$(int id)
|
||||
elif [ "$code" = 409 ]; then
|
||||
echo "[release] 409 on create — reusing existing release for tag $TAG"
|
||||
rid="$(find_release || true)"
|
||||
else
|
||||
echo "[release] ERROR: create returned HTTP $code: $(cat "$BODYF")" >&2; exit 1
|
||||
fi
|
||||
[ -n "${rid:-}" ] || { echo "[release] ERROR: no release id after create (HTTP $code)" >&2; cat "$BODYF" >&2; exit 1; }
|
||||
echo "[release] release id=$rid"
|
||||
|
||||
# --- upload assets, replacing any of the same name ---------------------------
|
||||
api GET "/releases/$rid/assets" >/dev/null || true
|
||||
assets_body="$(cat "$BODYF")"
|
||||
for f in "$@"; do
|
||||
[ -f "$f" ] || { echo "[release] skip missing $f"; continue; }
|
||||
base=$(basename "$f")
|
||||
# delete a pre-existing asset with this name (idempotent re-run)
|
||||
aid=$(printf '%s' "$assets_body" | sed -n 's/.*"id":[[:space:]]*\([0-9]*\)[^}]*"name":[[:space:]]*"'"$base"'".*/\1/p' | head -n1)
|
||||
[ -n "$aid" ] && api DELETE "/releases/$rid/assets/$aid" >/dev/null 2>&1 || true
|
||||
echo "[release] uploading $base"
|
||||
code=$(api POST "/releases/$rid/assets?name=$base" -F "attachment=@$f;filename=$base")
|
||||
case "$code" in
|
||||
201|200) ;;
|
||||
*) echo "[release] ERROR uploading $base: HTTP $code: $(cat "$BODYF")" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
rm -f "$BODYF"
|
||||
echo "[release] done: $SERVER/$REPO/releases/tag/$TAG"
|
||||
@@ -0,0 +1,210 @@
|
||||
// Command prizma runs the Prizma panel: a proxy in front of other subscription
|
||||
// panels that keeps one pinned upstream device (the x-hwid header) while
|
||||
// handing out a public link any number of devices may use.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
"github.com/omar/prizma/internal/api"
|
||||
"github.com/omar/prizma/internal/config"
|
||||
"github.com/omar/prizma/internal/store"
|
||||
"github.com/omar/prizma/internal/upstream"
|
||||
"github.com/omar/prizma/internal/webui"
|
||||
)
|
||||
|
||||
// version is stamped by the release build: -ldflags "-X main.version=v1.2.3".
|
||||
var version = "dev"
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
slog.Error("prizma stopped", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
cfg := config.Load()
|
||||
setupLogging(cfg.LogLevel)
|
||||
|
||||
if dir := filepath.Dir(cfg.DBPath); dir != "" && dir != "." {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("create data directory %s: %w", dir, err)
|
||||
}
|
||||
}
|
||||
|
||||
st, err := store.Open(cfg.DBPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open database %s: %w", cfg.DBPath, err)
|
||||
}
|
||||
defer func() {
|
||||
if err := st.Close(); err != nil {
|
||||
slog.Warn("closing the database", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
deps := api.Deps{Cfg: cfg, Store: st, Fetch: upstream.New(cfg, st)}
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: cfg.Addr,
|
||||
Handler: router(deps),
|
||||
// ReadHeaderTimeout is the one that matters against slowloris; the
|
||||
// write timeout is generous because an upstream fetch happens inside
|
||||
// the subscription handler.
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 90 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
ErrorLog: slog.NewLogLogger(slog.Default().Handler(), slog.LevelWarn),
|
||||
}
|
||||
|
||||
ln, err := net.Listen("tcp", cfg.Addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listen on %s: %w", cfg.Addr, err)
|
||||
}
|
||||
|
||||
banner(cfg, ln.Addr().String())
|
||||
|
||||
errc := make(chan error, 1)
|
||||
go func() {
|
||||
if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
errc <- err
|
||||
return
|
||||
}
|
||||
errc <- nil
|
||||
}()
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
select {
|
||||
case err := <-errc:
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
slog.Info("shutting down, waiting for in-flight requests")
|
||||
}
|
||||
|
||||
// Give live subscription fetches a chance to finish before the socket dies.
|
||||
shutCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(shutCtx); err != nil {
|
||||
return fmt.Errorf("graceful shutdown: %w", err)
|
||||
}
|
||||
slog.Info("bye")
|
||||
return nil
|
||||
}
|
||||
|
||||
func router(deps api.Deps) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.RequestID)
|
||||
if deps.Cfg.TrustProxy {
|
||||
// Only trust the forwarding headers when the operator says there IS a
|
||||
// proxy; otherwise any client could forge its own address.
|
||||
r.Use(middleware.RealIP)
|
||||
}
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(accessLog)
|
||||
|
||||
r.Handle("/healthz", api.Health())
|
||||
r.Mount("/api", api.Router(deps))
|
||||
r.Mount("/sub", api.SubscribeHandler(deps))
|
||||
r.Mount("/", webui.Handler())
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// accessLog emits one structured line per request. It deliberately does not log
|
||||
// the query string: ?hwid= would put a device identity in the log file.
|
||||
func accessLog(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
||||
next.ServeHTTP(ww, r)
|
||||
|
||||
level := slog.LevelInfo
|
||||
switch {
|
||||
case ww.Status() >= 500:
|
||||
level = slog.LevelError
|
||||
case ww.Status() >= 400:
|
||||
level = slog.LevelWarn
|
||||
case strings.HasPrefix(r.URL.Path, "/healthz"), strings.HasPrefix(r.URL.Path, "/assets/"):
|
||||
level = slog.LevelDebug
|
||||
}
|
||||
slog.Log(r.Context(), level, "http",
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"status", ww.Status(),
|
||||
"bytes", ww.BytesWritten(),
|
||||
"dur", time.Since(start).Round(time.Millisecond).String(),
|
||||
"ua", r.UserAgent(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func setupLogging(level string) {
|
||||
var lvl slog.Level
|
||||
switch strings.ToLower(strings.TrimSpace(level)) {
|
||||
case "debug":
|
||||
lvl = slog.LevelDebug
|
||||
case "warn", "warning":
|
||||
lvl = slog.LevelWarn
|
||||
case "error":
|
||||
lvl = slog.LevelError
|
||||
default:
|
||||
lvl = slog.LevelInfo
|
||||
}
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: lvl})))
|
||||
}
|
||||
|
||||
// banner prints where to point a browser, and shouts if the panel is still
|
||||
// reachable with the default password.
|
||||
func banner(cfg config.Config, addr string) {
|
||||
admin := cfg.BaseURL
|
||||
if admin == "" {
|
||||
admin = "http://" + humanAddr(addr)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stdout, "\n Prizma %s\n", version)
|
||||
fmt.Fprintf(os.Stdout, " admin panel %s\n", admin)
|
||||
fmt.Fprintf(os.Stdout, " subscriptions %s/sub/{token}\n", admin)
|
||||
fmt.Fprintf(os.Stdout, " database %s\n", cfg.DBPath)
|
||||
fmt.Fprintf(os.Stdout, " admin user %s\n\n", cfg.AdminUser)
|
||||
|
||||
if cfg.AdminPassword == "admin" {
|
||||
fmt.Fprint(os.Stdout,
|
||||
" ****************************************************************\n"+
|
||||
" * WARNING: the admin password is still the default \"admin\". *\n"+
|
||||
" * Anyone who can reach this port owns your subscriptions. *\n"+
|
||||
" * Set ADMIN_PASSWORD and restart before exposing Prizma. *\n"+
|
||||
" ****************************************************************\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
// humanAddr turns a wildcard listen address into something clickable.
|
||||
func humanAddr(addr string) string {
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return addr
|
||||
}
|
||||
if host == "" || host == "::" || host == "0.0.0.0" {
|
||||
host = "localhost"
|
||||
}
|
||||
if strings.Contains(host, ":") {
|
||||
host = "[" + host + "]"
|
||||
}
|
||||
return host + ":" + port
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
# Prizma — example Compose deployment.
|
||||
#
|
||||
# cp docker-compose.example.yml docker-compose.yml
|
||||
# cp .env.example .env # then edit ADMIN_PASSWORD
|
||||
# docker compose up -d
|
||||
#
|
||||
# Every environment key below is documented in .env.example. `${VAR:-default}`
|
||||
# means "take it from .env, otherwise use Prizma's own default" — so the only
|
||||
# key you MUST set is ADMIN_PASSWORD, which uses `${VAR:?...}` instead and stops
|
||||
# the stack rather than quietly starting with the password "admin".
|
||||
|
||||
services:
|
||||
prizma:
|
||||
image: git.qomar.pw/omar/prizma:latest
|
||||
container_name: prizma
|
||||
restart: unless-stopped
|
||||
|
||||
# Uncomment to build from a checkout instead of pulling the published image.
|
||||
# build:
|
||||
# context: .
|
||||
# args:
|
||||
# VERSION: dev
|
||||
|
||||
ports:
|
||||
# host:container. Bind to 127.0.0.1 when a reverse proxy on the same host
|
||||
# terminates TLS — Prizma itself serves plain HTTP.
|
||||
- "127.0.0.1:8080:8080"
|
||||
|
||||
environment:
|
||||
# --- network ---
|
||||
PRIZMA_ADDR: ":8080"
|
||||
PUBLIC_URL: "${PUBLIC_URL:-http://localhost:8080}"
|
||||
# Set TRUST_PROXY only when something in front of you rewrites the client
|
||||
# IP; a direct listener would let anyone forge X-Forwarded-For.
|
||||
TRUST_PROXY: "${TRUST_PROXY:-false}"
|
||||
|
||||
# --- storage: on the named volume mounted at /data ---
|
||||
PRIZMA_DB: "/data/prizma.db"
|
||||
|
||||
# --- admin auth ---
|
||||
ADMIN_USER: "${ADMIN_USER:-admin}"
|
||||
# No default on purpose: leaving this unset starts Prizma with the
|
||||
# password "admin" and a loud warning in the log. Set it in .env.
|
||||
ADMIN_PASSWORD: "${ADMIN_PASSWORD:?set ADMIN_PASSWORD in .env}"
|
||||
# Empty -> generated once and persisted in the DB settings table.
|
||||
JWT_SECRET: "${JWT_SECRET:-}"
|
||||
|
||||
# --- upstream fetching ---
|
||||
CACHE_TTL: "${CACHE_TTL:-300}"
|
||||
UPSTREAM_TIMEOUT: "${UPSTREAM_TIMEOUT:-20}"
|
||||
|
||||
# --- logging ---
|
||||
LOG_LEVEL: "${LOG_LEVEL:-info}"
|
||||
|
||||
# Container clock, for log timestamps. tzdata is installed in the image.
|
||||
TZ: "${TZ:-UTC}"
|
||||
|
||||
volumes:
|
||||
# The SQLite DB (plus its -wal/-shm siblings) lives here. This is the only
|
||||
# state Prizma has: back this up and you have backed up everything.
|
||||
- prizma-data:/data
|
||||
|
||||
# The image's HEALTHCHECK curls /healthz; this only tightens the schedule.
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/healthz"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
# Prizma writes only to /data and never spawns a subprocess.
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
volumes:
|
||||
prizma-data:
|
||||
@@ -0,0 +1,321 @@
|
||||
# Prizma — internal package contract
|
||||
|
||||
Frozen interfaces every package is written against. Do not change a signature
|
||||
here without changing this file; parallel work depends on it.
|
||||
|
||||
Module path: `github.com/omar/prizma`. Go 1.24. Pure Go only — the release
|
||||
cross-compiles to linux/amd64, linux/arm64, windows/amd64, darwin/arm64 with
|
||||
`CGO_ENABLED=0`, so no cgo dependency may enter the tree (that is why the DB is
|
||||
`modernc.org/sqlite`).
|
||||
|
||||
## What Prizma does
|
||||
|
||||
An upstream subscription panel (Remnawave and friends) pins a subscription to a
|
||||
single device through the `x-hwid` request header. Prizma sits in front:
|
||||
|
||||
- it stores the pinned HWID per **source** and presents it upstream,
|
||||
- it exposes its own public link that ANY number of devices may use,
|
||||
- everything else about the exchange — the client's User-Agent, the response
|
||||
body, the response headers (`profile-title`, `subscription-userinfo`,
|
||||
`announce`, `profile-update-interval`, …) — is proxied through untouched,
|
||||
- **groups** merge several sources into one link, with regex filtering.
|
||||
|
||||
Two link kinds: `source` (byte-exact proxy) and `group` (parse → filter →
|
||||
merge → render).
|
||||
|
||||
## internal/model
|
||||
|
||||
Owned by the scaffold, already written. `Node`, `Source`, `Group`,
|
||||
`GroupMember`, `Filter`, `Client`, `AccessDecision`, `Stats`.
|
||||
|
||||
Fidelity rule for `Node`: parsers fill exactly one of `RawURI` / `RawClash` /
|
||||
`RawSingBox` / `RawXray` plus `RawDoc`. A renderer targeting the same format
|
||||
family MUST emit the raw payload rather than re-deriving it.
|
||||
|
||||
## internal/subfmt — parse & render every subscription format
|
||||
|
||||
```go
|
||||
type Format string
|
||||
|
||||
const (
|
||||
FormatURIList Format = "uri" // plain, one proxy URI per line
|
||||
FormatBase64 Format = "base64" // base64(URI list) — the v2rayN classic
|
||||
FormatClash Format = "clash" // Clash / Clash.Meta / Mihomo YAML
|
||||
FormatSingBox Format = "singbox" // sing-box JSON {"outbounds":[...]}
|
||||
FormatXray Format = "xray" // Xray JSON config, or a Happ-style ARRAY of configs
|
||||
FormatUnknown Format = "unknown"
|
||||
)
|
||||
|
||||
// Detect sniffs the format from the body and the upstream Content-Type.
|
||||
func Detect(body []byte, contentType string) Format
|
||||
|
||||
// Parse decodes a subscription body into nodes, returning the format it used.
|
||||
func Parse(body []byte, contentType string) ([]model.Node, Format, error)
|
||||
|
||||
// Render serializes nodes. Returns the body and the Content-Type to send.
|
||||
func Render(nodes []model.Node, f Format, opts RenderOpts) ([]byte, string, error)
|
||||
|
||||
// FormatForUA negotiates an output format from a client User-Agent.
|
||||
// clash/mihomo/stash -> clash; sing-box/hiddify -> singbox; happ/v2rayn/
|
||||
// v2rayng/nekobox/streisand/shadowrocket -> base64; unknown -> base64.
|
||||
func FormatForUA(ua string) Format
|
||||
|
||||
// ParseFormat maps a ?format= query value (and its common aliases) to a Format.
|
||||
func ParseFormat(s string) (Format, bool)
|
||||
|
||||
type RenderOpts struct {
|
||||
ProfileTitle string // optional, informational
|
||||
// Template is the document skeleton to reuse (dns/routing/rules blocks).
|
||||
// When nil the renderer falls back to the first node's RawDoc, then to a
|
||||
// built-in minimal skeleton.
|
||||
Template map[string]any
|
||||
}
|
||||
```
|
||||
|
||||
Protocols to cover on the URI side, both directions: `vless://`, `vmess://`
|
||||
(base64 JSON and the plain variant), `trojan://`, `ss://` (both
|
||||
`base64(method:pass)@host:port` and fully-base64 legacy), `ssr://`,
|
||||
`hysteria://`, `hysteria2://` / `hy2://`, `tuic://`, `anytls://`, `socks://`,
|
||||
`http://`+`https://` proxy URIs, `wireguard://`. Unknown schemes must survive
|
||||
round-trip via `RawURI` instead of being dropped.
|
||||
|
||||
Robustness rules, all mandatory:
|
||||
|
||||
- base64 detection tolerates missing padding and URL-safe alphabets,
|
||||
- a body that is base64 of a *Clash YAML* or of *JSON* must still be detected
|
||||
correctly (decode first, then re-detect),
|
||||
- a single malformed line never fails the whole parse; it is skipped and
|
||||
counted,
|
||||
- node names are percent-decoded from the URI fragment and kept in UTF-8,
|
||||
- empty result with no error is a valid outcome for an empty subscription.
|
||||
|
||||
Tests live in `internal/subfmt/*_test.go` and use the real captures in
|
||||
`internal/subfmt/testdata/`:
|
||||
|
||||
| file | what it is |
|
||||
|---|---|
|
||||
| `live_happ.json` | Happ/Xray: JSON **array of 34 whole configs**, each with `remarks` + `outbounds` (27 vless + freedom + blackhole) |
|
||||
| `live_clash.yaml` | Clash YAML, 34 proxies (29 vless, 4 ss, 1 hysteria2) |
|
||||
| `live_b64.txt` | base64 of a 34-line URI list |
|
||||
| `live_v2rayn.txt` | the same subscription as v2rayN sees it |
|
||||
|
||||
Required test bar: every capture parses to 34 nodes (the Happ one to 34 too —
|
||||
one node per config element, `freedom`/`blackhole` outbounds are not nodes);
|
||||
every format renders and re-parses to the same node count and the same names.
|
||||
|
||||
## internal/store — SQLite persistence
|
||||
|
||||
```go
|
||||
type Store struct{ /* unexported */ }
|
||||
|
||||
func Open(path string) (*Store, error) // creates + migrates
|
||||
func (s *Store) Close() error
|
||||
|
||||
// Sources
|
||||
func (s *Store) ListSources() ([]model.Source, error)
|
||||
func (s *Store) GetSource(id int64) (*model.Source, error)
|
||||
func (s *Store) GetSourceByToken(tok string) (*model.Source, error)
|
||||
func (s *Store) CreateSource(src *model.Source) error // fills ID/Token/HWID
|
||||
func (s *Store) UpdateSource(src *model.Source) error
|
||||
func (s *Store) DeleteSource(id int64) error
|
||||
func (s *Store) RecordSourceFetch(id int64, ok bool, nodes int, errMsg string) error
|
||||
|
||||
// Groups
|
||||
func (s *Store) ListGroups() ([]model.Group, error)
|
||||
func (s *Store) GetGroup(id int64) (*model.Group, error)
|
||||
func (s *Store) GetGroupByToken(tok string) (*model.Group, error)
|
||||
func (s *Store) CreateGroup(g *model.Group) error
|
||||
func (s *Store) UpdateGroup(g *model.Group) error
|
||||
func (s *Store) DeleteGroup(id int64) error
|
||||
|
||||
// Clients / access control
|
||||
func (s *Store) TouchClient(c model.Client) (*model.Client, error) // upsert by HWID, bumps Hits/LastSeen
|
||||
func (s *Store) ListClients(q ClientQuery) ([]model.Client, int, error) // rows, total
|
||||
func (s *Store) SetClientBanned(id int64, banned bool) error
|
||||
func (s *Store) SetClientAllowed(id int64, allowed bool) error
|
||||
func (s *Store) SetClientLabel(id int64, label string) error
|
||||
func (s *Store) DeleteClient(id int64) error
|
||||
func (s *Store) WhitelistCount() (int, error)
|
||||
func (s *Store) LookupClient(hwid string) (*model.Client, error)
|
||||
|
||||
// Misc
|
||||
func (s *Store) Stats() (model.Stats, error)
|
||||
func (s *Store) LogRequest(link string, hwid string, status int) error
|
||||
func (s *Store) Setting(key string) (string, error)
|
||||
func (s *Store) SetSetting(key, value string) error
|
||||
|
||||
type ClientQuery struct {
|
||||
Search string // matches hwid/ua/ip/label
|
||||
Banned *bool
|
||||
Allowed *bool
|
||||
Limit int
|
||||
Offset int
|
||||
Sort string // "last_seen" (default) | "first_seen" | "hits"
|
||||
}
|
||||
```
|
||||
|
||||
Concurrency: the sqlite handle is opened with `_pragma=busy_timeout(5000)` and
|
||||
`_pragma=journal_mode(WAL)`; all writes go through a single `*sql.DB` — no
|
||||
package-level globals.
|
||||
|
||||
## internal/hwid — generation and the access gate
|
||||
|
||||
```go
|
||||
// New returns a fresh 32-char lowercase hex HWID (the shape Remnawave and Happ use).
|
||||
func New() string
|
||||
|
||||
// FromRequest extracts the client HWID: x-hwid header, then ?hwid=, then a
|
||||
// synthetic sha1(ua|ip)[:32] with synthetic=true.
|
||||
func FromRequest(r *http.Request) (id string, synthetic bool)
|
||||
|
||||
// Gate decides whether a client may fetch a link.
|
||||
// whitelist non-empty -> only whitelisted HWIDs pass
|
||||
// whitelist empty -> everyone passes except banned HWIDs
|
||||
// Banned always loses, including against the whitelist.
|
||||
func Gate(st *store.Store, c *model.Client) model.AccessDecision
|
||||
```
|
||||
|
||||
## internal/upstream — fetching a source
|
||||
|
||||
```go
|
||||
type Fetcher struct{ /* http client + cache */ }
|
||||
|
||||
func New(cfg config.Config, st *store.Store) *Fetcher
|
||||
|
||||
// Result is what the upstream answered, ready to be proxied verbatim.
|
||||
type Result struct {
|
||||
Body []byte
|
||||
Status int
|
||||
Header http.Header // upstream headers, hop-by-hop already stripped
|
||||
ContentType string
|
||||
FetchedAt time.Time
|
||||
FromCache bool
|
||||
}
|
||||
|
||||
// Fetch performs (or serves from cache) the upstream request for src.
|
||||
// clientUA and clientHeaders come from the incoming request; the HWID and
|
||||
// device headers are REPLACED with the source's own.
|
||||
func (f *Fetcher) Fetch(ctx context.Context, src model.Source, clientUA string, clientHeaders http.Header) (*Result, error)
|
||||
|
||||
// Nodes fetches and parses, for groups and for the UI preview.
|
||||
func (f *Fetcher) Nodes(ctx context.Context, src model.Source, clientUA string) ([]model.Node, subfmt.Format, error)
|
||||
|
||||
func (f *Fetcher) Invalidate(sourceID int64)
|
||||
```
|
||||
|
||||
Header policy, exactly:
|
||||
|
||||
- **Sent upstream**: `x-hwid` = source HWID; `x-device-os`, `x-ver-os`,
|
||||
`x-device-model` from the source when set; `User-Agent` per the source's
|
||||
`UserAgent`/`ForwardClientUA` rules; every `ExtraHeaders` entry; plus the
|
||||
client's `Accept`/`Accept-Language`. Nothing else from the client is
|
||||
forwarded — no cookies, no auth.
|
||||
- **Returned to the client**: every upstream response header EXCEPT hop-by-hop
|
||||
(`Connection`, `Keep-Alive`, `Transfer-Encoding`, `Upgrade`, `Trailer`,
|
||||
`TE`, `Proxy-*`), `Content-Length` (recomputed), `Content-Encoding` (the body
|
||||
is already decoded), and any `*hwid*` header (the upstream's HWID state must
|
||||
not leak to unlimited clients).
|
||||
|
||||
## internal/api — REST + admin auth
|
||||
|
||||
Router built with chi, mounted by `cmd/prizma`.
|
||||
|
||||
```go
|
||||
func Router(deps Deps) http.Handler
|
||||
|
||||
type Deps struct {
|
||||
Cfg config.Config
|
||||
Store *store.Store
|
||||
Fetch *upstream.Fetcher
|
||||
}
|
||||
```
|
||||
|
||||
Routes:
|
||||
|
||||
```
|
||||
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 -> supported format list, for the UI
|
||||
GET /healthz -> 200 "ok" (no auth)
|
||||
```
|
||||
|
||||
Auth: `POST /api/auth/login` checks `ADMIN_USER` / `ADMIN_PASSWORD` and returns
|
||||
an HS256 JWT signed with `JWT_SECRET` (auto-generated and persisted in the DB
|
||||
`settings` table when unset). Every other `/api/*` route requires
|
||||
`Authorization: Bearer <token>`. Public subscription routes are NOT under
|
||||
`/api`.
|
||||
|
||||
Public routes (registered by `cmd/prizma`, handler in `internal/api`):
|
||||
|
||||
```
|
||||
GET/HEAD /sub/{token} the subscription itself
|
||||
GET/HEAD /sub/{token}/{any} same, tolerates clients that append junk
|
||||
```
|
||||
|
||||
Behaviour of `/sub/{token}`:
|
||||
|
||||
1. resolve token -> source or group; 404 if unknown or disabled,
|
||||
2. HWID gate -> 403 on a ban / non-whitelisted client,
|
||||
3. `TouchClient` + `LogRequest`,
|
||||
4. source link: `Fetch` and stream the body through **unchanged**, with the
|
||||
upstream headers per the policy above,
|
||||
5. group link: fetch every enabled member concurrently, parse, filter, merge,
|
||||
dedupe, rename, then `Render` in the negotiated format. A member that fails
|
||||
is skipped, never fatal, unless every member fails (502).
|
||||
|
||||
Format negotiation for groups: `?format=` wins, then `Group.OutputFormat`, then
|
||||
`FormatForUA(client UA)`.
|
||||
|
||||
## internal/config
|
||||
|
||||
```go
|
||||
type Config struct {
|
||||
Addr string // PRIZMA_ADDR, default ":8080"
|
||||
DBPath string // PRIZMA_DB, default "data/prizma.db"
|
||||
AdminUser string // ADMIN_USER, default "admin"
|
||||
AdminPassword string // ADMIN_PASSWORD, default "admin" (warns loudly)
|
||||
JWTSecret string // JWT_SECRET, generated when empty
|
||||
BaseURL string // PUBLIC_URL, used to print links in the UI
|
||||
CacheTTL time.Duration // CACHE_TTL seconds, default 300
|
||||
UpstreamTimeout time.Duration // UPSTREAM_TIMEOUT seconds, default 20
|
||||
LogLevel string // LOG_LEVEL
|
||||
TrustProxy bool // TRUST_PROXY, honour X-Forwarded-For
|
||||
}
|
||||
|
||||
func Load() Config
|
||||
```
|
||||
|
||||
## internal/webui
|
||||
|
||||
```go
|
||||
//go:embed all:dist
|
||||
var dist embed.FS
|
||||
|
||||
// Handler serves the built SPA with history fallback to index.html.
|
||||
func Handler() http.Handler
|
||||
```
|
||||
|
||||
`web/dist` is copied to `internal/webui/dist` by the build. A committed
|
||||
placeholder `internal/webui/dist/index.html` keeps `go build` green before the
|
||||
frontend is built.
|
||||
|
||||
## web/ — React admin panel
|
||||
|
||||
Vite + React 18 + TypeScript, no CSS framework dependency beyond plain CSS
|
||||
modules or a single stylesheet. Pages: Login, Dashboard, Sources, Groups,
|
||||
Clients. Dev server proxies `/api` and `/sub` to `http://localhost:8080`.
|
||||
Build output must be relative-path safe (`base: './'`).
|
||||
@@ -0,0 +1,23 @@
|
||||
module github.com/omar/prizma
|
||||
|
||||
go 1.24
|
||||
|
||||
require (
|
||||
github.com/go-chi/chi/v5 v5.2.1
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.36.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 // indirect
|
||||
golang.org/x/sys v0.31.0 // indirect
|
||||
modernc.org/libc v1.61.13 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.8.2 // indirect
|
||||
)
|
||||
@@ -0,0 +1,55 @@
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
|
||||
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 h1:pVgRXcIictcr+lBQIFeiwuwtDIs4eL21OuM9nyAADmo=
|
||||
golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
|
||||
golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8=
|
||||
golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg=
|
||||
golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.24.4 h1:TFkx1s6dCkQpd6dKurBNmpo+G8Zl4Sq/ztJ+2+DEsh0=
|
||||
modernc.org/cc/v4 v4.24.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.23.16 h1:Z2N+kk38b7SfySC1ZkpGLN2vthNJP1+ZzGZIlH7uBxo=
|
||||
modernc.org/ccgo/v4 v4.23.16/go.mod h1:nNma8goMTY7aQZQNTyN9AIoJfxav4nvTnvKThAeMDdo=
|
||||
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||
modernc.org/gc/v2 v2.6.3 h1:aJVhcqAte49LF+mGveZ5KPlsp4tdGdAOT4sipJXADjw=
|
||||
modernc.org/gc/v2 v2.6.3/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/libc v1.61.13 h1:3LRd6ZO1ezsFiX1y+bHd1ipyEHIJKvuprv0sLTBwLW8=
|
||||
modernc.org/libc v1.61.13/go.mod h1:8F/uJWL/3nNil0Lgt1Dpz+GgkApWh04N3el3hxJcA6E=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.8.2 h1:cL9L4bcoAObu4NkxOlKWBWtNHIsnnACGF/TbqQ6sbcI=
|
||||
modernc.org/memory v1.8.2/go.mod h1:ZbjSvMO5NQ1A2i3bWeDiVMxIorXwdClKE/0SZ+BMotU=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.36.0 h1:EQXNRn4nIS+gfsKeUTymHIz1waxuv5BzU7558dHSfH8=
|
||||
modernc.org/sqlite v1.36.0/go.mod h1:7MPwH7Z6bREicF9ZVUR78P1IKuxfZ8mRIDHD0iD+8TU=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
@@ -0,0 +1,725 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/omar/prizma/internal/config"
|
||||
"github.com/omar/prizma/internal/model"
|
||||
"github.com/omar/prizma/internal/store"
|
||||
"github.com/omar/prizma/internal/upstream"
|
||||
)
|
||||
|
||||
const (
|
||||
testUser = "root"
|
||||
testPass = "correct horse battery staple"
|
||||
)
|
||||
|
||||
type env struct {
|
||||
t *testing.T
|
||||
st *store.Store
|
||||
srv *httptest.Server
|
||||
}
|
||||
|
||||
func newEnv(t *testing.T) *env {
|
||||
t.Helper()
|
||||
|
||||
st, err := store.Open(filepath.Join(t.TempDir(), "prizma.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("store.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
|
||||
cfg := config.Config{
|
||||
AdminUser: testUser,
|
||||
AdminPassword: testPass,
|
||||
JWTSecret: "test-signing-secret",
|
||||
CacheTTL: time.Minute,
|
||||
UpstreamTimeout: 5 * time.Second,
|
||||
}
|
||||
deps := Deps{Cfg: cfg, Store: st, Fetch: upstream.New(cfg, st)}
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Handle("/healthz", Health())
|
||||
r.Mount("/api", Router(deps))
|
||||
r.Mount("/sub", SubscribeHandler(deps))
|
||||
|
||||
srv := httptest.NewServer(r)
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
return &env{t: t, st: st, srv: srv}
|
||||
}
|
||||
|
||||
// do issues a request against the panel. hdr entries are applied verbatim.
|
||||
func (e *env) do(method, path string, hdr map[string]string, body any) *http.Response {
|
||||
e.t.Helper()
|
||||
|
||||
var rdr io.Reader
|
||||
if body != nil {
|
||||
buf, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
e.t.Fatalf("marshal body: %v", err)
|
||||
}
|
||||
rdr = bytes.NewReader(buf)
|
||||
}
|
||||
req, err := http.NewRequest(method, e.srv.URL+path, rdr)
|
||||
if err != nil {
|
||||
e.t.Fatalf("new request: %v", err)
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
for k, v := range hdr {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := e.srv.Client().Do(req)
|
||||
if err != nil {
|
||||
e.t.Fatalf("%s %s: %v", method, path, err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func (e *env) token() string {
|
||||
e.t.Helper()
|
||||
resp := e.do(http.MethodPost, "/api/auth/login", nil, loginRequest{Username: testUser, Password: testPass})
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
e.t.Fatalf("login status = %d", resp.StatusCode)
|
||||
}
|
||||
var out loginResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
e.t.Fatalf("decode login: %v", err)
|
||||
}
|
||||
return out.Token
|
||||
}
|
||||
|
||||
func (e *env) auth() map[string]string {
|
||||
return map[string]string{"Authorization": "Bearer " + e.token()}
|
||||
}
|
||||
|
||||
func readBody(t *testing.T, resp *http.Response) string {
|
||||
t.Helper()
|
||||
defer resp.Body.Close()
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read body: %v", err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// upstreamPanel stands in for Remnawave: it records the HWID it was shown and
|
||||
// answers with a body plus the header set a real panel sends.
|
||||
type upstreamPanel struct {
|
||||
*httptest.Server
|
||||
sawHWID atomic.Value // string
|
||||
sawUA atomic.Value // string
|
||||
calls atomic.Int64
|
||||
}
|
||||
|
||||
func newPanel(t *testing.T, body string) *upstreamPanel {
|
||||
t.Helper()
|
||||
p := &upstreamPanel{}
|
||||
p.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
p.calls.Add(1)
|
||||
p.sawHWID.Store(r.Header.Get("x-hwid"))
|
||||
p.sawUA.Store(r.Header.Get("User-Agent"))
|
||||
|
||||
h := w.Header()
|
||||
h.Set("Content-Type", "text/plain; charset=utf-8")
|
||||
h.Set("profile-title", "base64:UmVtbmF3YXZl")
|
||||
h.Set("subscription-userinfo", "upload=0; download=12345; total=107374182400; expire=1799999999")
|
||||
h.Set("profile-update-interval", "6")
|
||||
h.Set("announce", "base64:aGVsbG8=")
|
||||
// The upstream's own device state. None of this may reach the client.
|
||||
h.Set("x-hwid", "UPSTREAM-PINNED-DEVICE")
|
||||
h.Set("X-Client-HWID", "UPSTREAM-PINNED-DEVICE")
|
||||
h.Set("x-hwid-limit", "1")
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
t.Cleanup(p.Close)
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *upstreamPanel) hwid() string {
|
||||
v, _ := p.sawHWID.Load().(string)
|
||||
return v
|
||||
}
|
||||
|
||||
func (p *upstreamPanel) ua() string {
|
||||
v, _ := p.sawUA.Load().(string)
|
||||
return v
|
||||
}
|
||||
|
||||
func mustSource(t *testing.T, st *store.Store, src *model.Source) *model.Source {
|
||||
t.Helper()
|
||||
if err := st.CreateSource(src); err != nil {
|
||||
t.Fatalf("CreateSource: %v", err)
|
||||
}
|
||||
return src
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The three properties the whole product rests on.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// (a) A source link proxies the upstream body unchanged while presenting the
|
||||
// source's own HWID upstream instead of the client's.
|
||||
func TestSourceLinkProxiesBodyVerbatimAndSwapsHWID(t *testing.T) {
|
||||
// Deliberately awkward: no trailing newline, CRLF, padding and non-ASCII.
|
||||
const body = "dmxlc3M6Ly9hYmM=\r\ntrojan://p@héllo.example:443#Ünïcodé \r\n\tvmess://eyJ2IjoiMiJ9"
|
||||
|
||||
e := newEnv(t)
|
||||
panel := newPanel(t, body)
|
||||
|
||||
src := mustSource(t, e.st, &model.Source{
|
||||
Name: "remna",
|
||||
URL: panel.URL,
|
||||
HWID: "aaaaaaaabbbbbbbbccccccccdddddddd",
|
||||
Enabled: true,
|
||||
})
|
||||
|
||||
resp := e.do(http.MethodGet, "/sub/"+src.Token, map[string]string{
|
||||
"x-hwid": "client-device-0001",
|
||||
"User-Agent": "clash-verge/1.7.0",
|
||||
"Cookie": "session=must-not-travel",
|
||||
}, nil)
|
||||
got := readBody(t, resp)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
if got != body {
|
||||
t.Errorf("body was not proxied byte-for-byte:\n got %q\nwant %q", got, body)
|
||||
}
|
||||
if panel.hwid() != src.HWID {
|
||||
t.Errorf("upstream saw x-hwid %q, want the source HWID %q", panel.hwid(), src.HWID)
|
||||
}
|
||||
if panel.hwid() == "client-device-0001" {
|
||||
t.Error("the client's own HWID was forwarded upstream")
|
||||
}
|
||||
// An empty Source.UserAgent means "forward the client's UA" — that is what
|
||||
// makes end-to-end format negotiation work.
|
||||
if panel.ua() != "clash-verge/1.7.0" {
|
||||
t.Errorf("upstream saw UA %q, want the client UA forwarded", panel.ua())
|
||||
}
|
||||
// Informational headers must survive the trip.
|
||||
if v := resp.Header.Get("profile-title"); v != "base64:UmVtbmF3YXZl" {
|
||||
t.Errorf("profile-title = %q, want it proxied through", v)
|
||||
}
|
||||
if v := resp.Header.Get("subscription-userinfo"); !strings.Contains(v, "total=107374182400") {
|
||||
t.Errorf("subscription-userinfo = %q, want it proxied through", v)
|
||||
}
|
||||
if v := resp.Header.Get("announce"); v != "base64:aGVsbG8=" {
|
||||
t.Errorf("announce = %q, want it proxied through", v)
|
||||
}
|
||||
}
|
||||
|
||||
// (b) No response header carrying upstream HWID state ever reaches the client.
|
||||
func TestUpstreamHWIDHeadersAreStripped(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
panel := newPanel(t, "vless://x@a.example:443#n")
|
||||
|
||||
src := mustSource(t, e.st, &model.Source{Name: "remna", URL: panel.URL, Enabled: true})
|
||||
|
||||
resp := e.do(http.MethodGet, "/sub/"+src.Token, map[string]string{"x-hwid": "dev1"}, nil)
|
||||
defer resp.Body.Close()
|
||||
|
||||
for name, values := range resp.Header {
|
||||
if strings.Contains(strings.ToLower(name), "hwid") {
|
||||
t.Errorf("header %s=%v leaked the upstream HWID state to the client", name, values)
|
||||
}
|
||||
}
|
||||
// Content-Encoding must be gone too: the body handed over is decoded.
|
||||
if v := resp.Header.Get("Content-Encoding"); v != "" {
|
||||
t.Errorf("Content-Encoding = %q, want it dropped", v)
|
||||
}
|
||||
}
|
||||
|
||||
// (c) A banned device is refused, and is still recorded so the operator can see
|
||||
// (and later un-ban) it.
|
||||
func TestBannedClientGetsForbidden(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
panel := newPanel(t, "vless://x@a.example:443#n")
|
||||
src := mustSource(t, e.st, &model.Source{Name: "remna", URL: panel.URL, Enabled: true})
|
||||
|
||||
const device = "0011223344556677889900aabbccddee"
|
||||
|
||||
resp := e.do(http.MethodGet, "/sub/"+src.Token, map[string]string{"x-hwid": device}, nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("first request status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
|
||||
rows, _, err := e.st.ListClients(store.ClientQuery{Search: device, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("ListClients: %v", err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("the device was not recorded (%d rows)", len(rows))
|
||||
}
|
||||
if err := e.st.SetClientBanned(rows[0].ID, true); err != nil {
|
||||
t.Fatalf("SetClientBanned: %v", err)
|
||||
}
|
||||
|
||||
before := panel.calls.Load()
|
||||
resp = e.do(http.MethodGet, "/sub/"+src.Token, map[string]string{"x-hwid": device}, nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("banned device status = %d, want 403", resp.StatusCode)
|
||||
}
|
||||
if panel.calls.Load() != before {
|
||||
t.Error("a banned device still caused an upstream fetch")
|
||||
}
|
||||
|
||||
// Another device is unaffected: a ban is not a whitelist.
|
||||
resp = e.do(http.MethodGet, "/sub/"+src.Token, map[string]string{"x-hwid": "ffffffffffffffffffffffffffffffff"}, nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("unrelated device status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public link behaviour
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestWhitelistLocksOutEverybodyElse(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
panel := newPanel(t, "vless://x@a.example:443#n")
|
||||
src := mustSource(t, e.st, &model.Source{Name: "remna", URL: panel.URL, Enabled: true})
|
||||
|
||||
const chosen = "11111111111111111111111111111111"
|
||||
resp := e.do(http.MethodGet, "/sub/"+src.Token, map[string]string{"x-hwid": chosen}, nil)
|
||||
resp.Body.Close()
|
||||
|
||||
rows, _, err := e.st.ListClients(store.ClientQuery{Search: chosen, Limit: 10})
|
||||
if err != nil || len(rows) != 1 {
|
||||
t.Fatalf("ListClients: %v (%d rows)", err, len(rows))
|
||||
}
|
||||
if err := e.st.SetClientAllowed(rows[0].ID, true); err != nil {
|
||||
t.Fatalf("SetClientAllowed: %v", err)
|
||||
}
|
||||
|
||||
resp = e.do(http.MethodGet, "/sub/"+src.Token, map[string]string{"x-hwid": chosen}, nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("whitelisted device status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
|
||||
resp = e.do(http.MethodGet, "/sub/"+src.Token, map[string]string{"x-hwid": "22222222222222222222222222222222"}, nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("non-whitelisted device status = %d, want 403", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadAndJunkSuffixAreTolerated(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
const body = "vless://x@a.example:443#n"
|
||||
panel := newPanel(t, body)
|
||||
src := mustSource(t, e.st, &model.Source{Name: "remna", URL: panel.URL, Enabled: true})
|
||||
|
||||
resp := e.do(http.MethodHead, "/sub/"+src.Token, nil, nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("HEAD status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
if resp.ContentLength != int64(len(body)) {
|
||||
t.Errorf("HEAD Content-Length = %d, want %d", resp.ContentLength, len(body))
|
||||
}
|
||||
|
||||
// Clients that append junk to the link must still be served.
|
||||
for _, path := range []string{"/sub/" + src.Token + "/clash", "/sub/" + src.Token + ".yaml"} {
|
||||
resp := e.do(http.MethodGet, path, nil, nil)
|
||||
got := readBody(t, resp)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("GET %s status = %d, want 200", path, resp.StatusCode)
|
||||
}
|
||||
if got != body {
|
||||
t.Errorf("GET %s body = %q", path, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownAndDisabledLinksAre404(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
panel := newPanel(t, "vless://x@a.example:443#n")
|
||||
src := mustSource(t, e.st, &model.Source{Name: "remna", URL: panel.URL, Enabled: false})
|
||||
|
||||
for _, path := range []string{"/sub/nope-nope", "/sub/" + src.Token} {
|
||||
resp := e.do(http.MethodGet, path, nil, nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("GET %s status = %d, want 404", path, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupMergesMembersAndNegotiatesFormat(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
a := newPanel(t, "vless://11111111-1111-1111-1111-111111111111@a.example:443?security=tls#Alpha")
|
||||
b := newPanel(t, "trojan://pw@b.example:443#Beta")
|
||||
|
||||
s1 := mustSource(t, e.st, &model.Source{Name: "A", URL: a.URL, Enabled: true})
|
||||
s2 := mustSource(t, e.st, &model.Source{Name: "B", URL: b.URL, Enabled: true})
|
||||
|
||||
g := &model.Group{
|
||||
Name: "merged",
|
||||
Enabled: true,
|
||||
Members: []model.GroupMember{{SourceID: s1.ID}, {SourceID: s2.ID}},
|
||||
}
|
||||
if err := e.st.CreateGroup(g); err != nil {
|
||||
t.Fatalf("CreateGroup: %v", err)
|
||||
}
|
||||
|
||||
resp := e.do(http.MethodGet, "/sub/"+g.Token+"?format=uri", nil, nil)
|
||||
got := readBody(t, resp)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200: %s", resp.StatusCode, got)
|
||||
}
|
||||
if !strings.Contains(got, "Alpha") || !strings.Contains(got, "Beta") {
|
||||
t.Errorf("merged body is missing a member:\n%s", got)
|
||||
}
|
||||
|
||||
// ?format= wins; without it the UA decides.
|
||||
resp = e.do(http.MethodGet, "/sub/"+g.Token, map[string]string{"User-Agent": "clash-verge/1.7.0"}, nil)
|
||||
body := readBody(t, resp)
|
||||
if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "yaml") {
|
||||
t.Errorf("a Clash UA got Content-Type %q, want YAML", ct)
|
||||
}
|
||||
if !strings.Contains(body, "proxies:") {
|
||||
t.Errorf("Clash render has no proxies block:\n%s", body)
|
||||
}
|
||||
if resp.Header.Get("profile-title") == "" {
|
||||
t.Error("group response carries no profile-title")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupSkipsADeadMemberButFailsWhenAllDie(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
good := newPanel(t, "vless://11111111-1111-1111-1111-111111111111@a.example:443#Alive")
|
||||
|
||||
s1 := mustSource(t, e.st, &model.Source{Name: "alive", URL: good.URL, Enabled: true})
|
||||
// Port 0 on loopback always refuses.
|
||||
s2 := mustSource(t, e.st, &model.Source{Name: "dead", URL: "http://127.0.0.1:0/sub", Enabled: true})
|
||||
|
||||
partial := &model.Group{
|
||||
Name: "partial", Enabled: true,
|
||||
Members: []model.GroupMember{{SourceID: s1.ID}, {SourceID: s2.ID}},
|
||||
}
|
||||
if err := e.st.CreateGroup(partial); err != nil {
|
||||
t.Fatalf("CreateGroup: %v", err)
|
||||
}
|
||||
resp := e.do(http.MethodGet, "/sub/"+partial.Token+"?format=uri", nil, nil)
|
||||
got := readBody(t, resp)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("one dead member made the group fail: status %d", resp.StatusCode)
|
||||
}
|
||||
if !strings.Contains(got, "Alive") {
|
||||
t.Errorf("the surviving member is missing:\n%s", got)
|
||||
}
|
||||
|
||||
allDead := &model.Group{
|
||||
Name: "all dead", Enabled: true,
|
||||
Members: []model.GroupMember{{SourceID: s2.ID}},
|
||||
}
|
||||
if err := e.st.CreateGroup(allDead); err != nil {
|
||||
t.Fatalf("CreateGroup: %v", err)
|
||||
}
|
||||
resp = e.do(http.MethodGet, "/sub/"+allDead.Token, nil, nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadGateway {
|
||||
t.Errorf("a group whose every member failed returned %d, want 502", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupFilterAndRenameApply(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
panel := newPanel(t, strings.Join([]string{
|
||||
"vless://11111111-1111-1111-1111-111111111111@nl.example:443#NL Amsterdam",
|
||||
"vless://22222222-2222-2222-2222-222222222222@de.example:443#DE Frankfurt",
|
||||
}, "\n"))
|
||||
s := mustSource(t, e.st, &model.Source{Name: "panel", URL: panel.URL, Enabled: true})
|
||||
|
||||
g := &model.Group{
|
||||
Name: "nl only", Enabled: true,
|
||||
Members: []model.GroupMember{{SourceID: s.ID}},
|
||||
Filter: model.Filter{IncludeName: "^NL"},
|
||||
NameTemplate: "{source} :: {name}",
|
||||
OutputFormat: "uri",
|
||||
}
|
||||
if err := e.st.CreateGroup(g); err != nil {
|
||||
t.Fatalf("CreateGroup: %v", err)
|
||||
}
|
||||
|
||||
resp := e.do(http.MethodGet, "/sub/"+g.Token, nil, nil)
|
||||
got := readBody(t, resp)
|
||||
if strings.Contains(got, "Frankfurt") {
|
||||
t.Errorf("the exclude filter did not apply:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "panel%20::%20NL") && !strings.Contains(got, "panel :: NL") {
|
||||
t.Errorf("the name template did not apply:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Admin API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestHealthzNeedsNoAuth(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
resp := e.do(http.MethodGet, "/healthz", nil, nil)
|
||||
if got := readBody(t, resp); got != "ok" || resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("/healthz = %d %q", resp.StatusCode, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIRequiresBearerToken(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
|
||||
for _, path := range []string{"/api/sources", "/api/groups", "/api/clients", "/api/stats", "/api/access", "/api/auth/me"} {
|
||||
resp := e.do(http.MethodGet, path, nil, nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("GET %s without a token = %d, want 401", path, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// A token signed with the wrong key must not pass either.
|
||||
resp := e.do(http.MethodGet, "/api/sources", map[string]string{
|
||||
"Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJyb290In0.bogus",
|
||||
}, nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("forged token = %d, want 401", resp.StatusCode)
|
||||
}
|
||||
|
||||
resp = e.do(http.MethodGet, "/api/sources", e.auth(), nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("authenticated GET /api/sources = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginRejectsBadCredentialsAndRateLimits(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
|
||||
for i := 0; i < loginAttempts; i++ {
|
||||
resp := e.do(http.MethodPost, "/api/auth/login", nil, loginRequest{Username: testUser, Password: "wrong"})
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("attempt %d = %d, want 401", i, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
resp := e.do(http.MethodPost, "/api/auth/login", nil, loginRequest{Username: testUser, Password: testPass})
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusTooManyRequests {
|
||||
t.Errorf("after %d failures the login returned %d, want 429", loginAttempts, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceCRUDAndHWIDRotation(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
panel := newPanel(t, "vless://11111111-1111-1111-1111-111111111111@a.example:443#Alpha")
|
||||
auth := e.auth()
|
||||
|
||||
resp := e.do(http.MethodPost, "/api/sources", auth, map[string]any{
|
||||
"name": "panel one", "url": panel.URL, "enabled": true,
|
||||
})
|
||||
var created model.Source
|
||||
if err := json.NewDecoder(resp.Body).Decode(&created); err != nil {
|
||||
t.Fatalf("decode created source: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if created.ID == 0 || created.Token == "" || created.HWID == "" {
|
||||
t.Fatalf("store did not fill id/token/hwid: %+v", created)
|
||||
}
|
||||
|
||||
// A bad URL is rejected before it ever reaches the network.
|
||||
resp = e.do(http.MethodPost, "/api/sources", auth, map[string]any{"name": "bad", "url": "ftp://nope"})
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("ftp:// URL = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
|
||||
path := "/api/sources/" + itoa(created.ID)
|
||||
|
||||
resp = e.do(http.MethodPost, path+"/test", auth, nil)
|
||||
var tested testResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&tested); err != nil {
|
||||
t.Fatalf("decode test response: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if !tested.OK || tested.Count != 1 {
|
||||
t.Errorf("test = %+v, want ok with 1 node", tested)
|
||||
}
|
||||
|
||||
resp = e.do(http.MethodPost, path+"/rotate-hwid", auth, nil)
|
||||
var rotated model.Source
|
||||
_ = json.NewDecoder(resp.Body).Decode(&rotated)
|
||||
resp.Body.Close()
|
||||
if rotated.HWID == created.HWID || len(rotated.HWID) != 32 {
|
||||
t.Errorf("hwid rotation produced %q (was %q)", rotated.HWID, created.HWID)
|
||||
}
|
||||
|
||||
// An update that omits the hwid must not lose it.
|
||||
resp = e.do(http.MethodPut, path, auth, map[string]any{"name": "renamed", "url": panel.URL})
|
||||
var updated model.Source
|
||||
_ = json.NewDecoder(resp.Body).Decode(&updated)
|
||||
resp.Body.Close()
|
||||
if updated.Name != "renamed" {
|
||||
t.Errorf("name = %q", updated.Name)
|
||||
}
|
||||
if updated.HWID != rotated.HWID {
|
||||
t.Errorf("hwid changed on an unrelated edit: %q -> %q", rotated.HWID, updated.HWID)
|
||||
}
|
||||
|
||||
resp = e.do(http.MethodDelete, path, auth, nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Errorf("delete = %d, want 204", resp.StatusCode)
|
||||
}
|
||||
resp = e.do(http.MethodGet, path, auth, nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("get after delete = %d, want 404", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupValidationRejectsBrokenRegexAndMissingSource(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
auth := e.auth()
|
||||
|
||||
resp := e.do(http.MethodPost, "/api/groups", auth, map[string]any{
|
||||
"name": "broken",
|
||||
"filter": map[string]any{"include_name": "([unclosed"},
|
||||
})
|
||||
body := readBody(t, resp)
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("broken regex = %d, want 400 (%s)", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
resp = e.do(http.MethodPost, "/api/groups", auth, map[string]any{
|
||||
"name": "ghost",
|
||||
"members": []map[string]any{{"source_id": 4242}},
|
||||
})
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("member pointing at a missing source = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientAdministration(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
panel := newPanel(t, "vless://x@a.example:443#n")
|
||||
src := mustSource(t, e.st, &model.Source{Name: "remna", URL: panel.URL, Enabled: true})
|
||||
auth := e.auth()
|
||||
|
||||
const device = "aaaa1111bbbb2222cccc3333dddd4444"
|
||||
resp := e.do(http.MethodGet, "/sub/"+src.Token, map[string]string{"x-hwid": device, "User-Agent": "Happ/1.0"}, nil)
|
||||
resp.Body.Close()
|
||||
|
||||
resp = e.do(http.MethodGet, "/api/clients?search="+device, auth, nil)
|
||||
var list clientsResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&list); err != nil {
|
||||
t.Fatalf("decode clients: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if list.Total != 1 || len(list.Items) != 1 {
|
||||
t.Fatalf("clients = %+v, want exactly the one device", list)
|
||||
}
|
||||
c := list.Items[0]
|
||||
if c.UserAgent != "Happ/1.0" || c.Synthetic {
|
||||
t.Errorf("client record = %+v, want the real UA and a non-synthetic hwid", c)
|
||||
}
|
||||
|
||||
base := "/api/clients/" + itoa(c.ID)
|
||||
for _, step := range []string{"/ban", "/unban", "/allow", "/disallow"} {
|
||||
resp := e.do(http.MethodPost, base+step, auth, nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("POST %s = %d, want 200", base+step, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
resp = e.do(http.MethodPost, base+"/label", auth, map[string]any{"label": "Omar's phone"})
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("label = %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
resp = e.do(http.MethodGet, "/api/access", auth, nil)
|
||||
var access accessResponse
|
||||
_ = json.NewDecoder(resp.Body).Decode(&access)
|
||||
resp.Body.Close()
|
||||
if access.WhitelistActive {
|
||||
t.Errorf("whitelist reported active after disallow: %+v", access)
|
||||
}
|
||||
|
||||
resp = e.do(http.MethodDelete, base, auth, nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Errorf("delete client = %d, want 204", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatsCountsTheRequest(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
panel := newPanel(t, "vless://x@a.example:443#n")
|
||||
src := mustSource(t, e.st, &model.Source{Name: "remna", URL: panel.URL, Enabled: true})
|
||||
|
||||
resp := e.do(http.MethodGet, "/sub/"+src.Token, map[string]string{"x-hwid": "abc123"}, nil)
|
||||
resp.Body.Close()
|
||||
|
||||
resp = e.do(http.MethodGet, "/api/stats", e.auth(), nil)
|
||||
var stats model.Stats
|
||||
if err := json.NewDecoder(resp.Body).Decode(&stats); err != nil {
|
||||
t.Fatalf("decode stats: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if stats.Sources != 1 || stats.Clients != 1 || stats.TotalRequests < 1 {
|
||||
t.Errorf("stats = %+v", stats)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyDownstreamHeadersDropsTheRightThings(t *testing.T) {
|
||||
in := http.Header{}
|
||||
in.Set("Content-Type", "text/plain")
|
||||
in.Set("Content-Length", "999")
|
||||
in.Set("Content-Encoding", "gzip")
|
||||
in.Set("Connection", "keep-alive")
|
||||
in.Set("Transfer-Encoding", "chunked")
|
||||
in.Set("Proxy-Authenticate", "Basic")
|
||||
in.Set("X-HWID", "leak")
|
||||
in.Set("x-client-hwid", "leak")
|
||||
in.Set("HWID-Limit", "1")
|
||||
in.Set("Profile-Title", "keep me")
|
||||
in.Add("Set-Cookie", "a=1")
|
||||
|
||||
out := http.Header{}
|
||||
copyDownstreamHeaders(out, in)
|
||||
|
||||
for _, dropped := range []string{"Content-Length", "Content-Encoding", "Connection", "Transfer-Encoding", "Proxy-Authenticate", "X-HWID", "x-client-hwid", "HWID-Limit"} {
|
||||
if v := out.Get(dropped); v != "" {
|
||||
t.Errorf("%s should have been dropped, got %q", dropped, v)
|
||||
}
|
||||
}
|
||||
if out.Get("Profile-Title") != "keep me" {
|
||||
t.Error("Profile-Title was not relayed")
|
||||
}
|
||||
if out.Get("Content-Type") != "text/plain" {
|
||||
t.Error("Content-Type was not relayed")
|
||||
}
|
||||
}
|
||||
|
||||
func itoa(v int64) string { return strconv.FormatInt(v, 10) }
|
||||
@@ -0,0 +1,247 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
||||
"github.com/omar/prizma/internal/config"
|
||||
"github.com/omar/prizma/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
tokenTTL = 12 * time.Hour
|
||||
tokenIssuer = "prizma"
|
||||
|
||||
// settingJWTSecret is where a generated signing key is persisted, so
|
||||
// restarting Prizma does not log every admin session out.
|
||||
settingJWTSecret = "jwt_secret"
|
||||
|
||||
loginAttempts = 8
|
||||
loginWindow = 5 * time.Minute
|
||||
)
|
||||
|
||||
type ctxKey int
|
||||
|
||||
const ctxKeyUser ctxKey = iota
|
||||
|
||||
// resolveSecret picks the HS256 signing key: the configured one, else the one
|
||||
// persisted on a previous run, else a fresh random key which is persisted now.
|
||||
func resolveSecret(cfg config.Config, st *store.Store) []byte {
|
||||
if s := strings.TrimSpace(cfg.JWTSecret); s != "" {
|
||||
return []byte(s)
|
||||
}
|
||||
if st != nil {
|
||||
if s, err := st.Setting(settingJWTSecret); err == nil && strings.TrimSpace(s) != "" {
|
||||
return []byte(s)
|
||||
}
|
||||
}
|
||||
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
// crypto/rand failing means the process cannot be trusted to sign
|
||||
// anything; there is no safe fallback.
|
||||
panic("prizma: cannot read random bytes for the JWT secret: " + err.Error())
|
||||
}
|
||||
secret := hex.EncodeToString(raw)
|
||||
if st != nil {
|
||||
if err := st.SetSetting(settingJWTSecret, secret); err != nil {
|
||||
slog.Default().Warn("could not persist generated JWT secret; sessions will not survive a restart", "err", err)
|
||||
}
|
||||
}
|
||||
return []byte(secret)
|
||||
}
|
||||
|
||||
// key returns the HS256 signing key, resolving (and persisting) it on first use.
|
||||
func (a *api) key() []byte {
|
||||
a.secretOnce.Do(func() { a.secret = resolveSecret(a.cfg, a.st) })
|
||||
return a.secret
|
||||
}
|
||||
|
||||
type loginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type loginResponse struct {
|
||||
Token string `json:"token"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
func (a *api) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
ip := clientIP(r, a.cfg.TrustProxy)
|
||||
if !a.limiter.allow(ip) {
|
||||
w.Header().Set("Retry-After", "60")
|
||||
writeErr(w, http.StatusTooManyRequests, "too many failed logins, try again later")
|
||||
return
|
||||
}
|
||||
|
||||
var req loginRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
|
||||
// Both comparisons always run and are combined without short-circuiting,
|
||||
// so response time does not reveal whether the username was the wrong part.
|
||||
userOK := constantTimeEqual(req.Username, a.cfg.AdminUser)
|
||||
passOK := constantTimeEqual(req.Password, a.cfg.AdminPassword)
|
||||
if !(userOK && passOK) {
|
||||
a.limiter.fail(ip)
|
||||
a.log.Warn("failed admin login", "ip", ip, "user", req.Username)
|
||||
writeErr(w, http.StatusUnauthorized, "invalid credentials")
|
||||
return
|
||||
}
|
||||
a.limiter.reset(ip)
|
||||
|
||||
tok, exp, err := a.issue(a.cfg.AdminUser)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "could not issue a token")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, loginResponse{Token: tok, ExpiresAt: exp})
|
||||
}
|
||||
|
||||
func (a *api) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := r.Context().Value(ctxKeyUser).(string)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"username": user,
|
||||
"base_url": a.cfg.BaseURL,
|
||||
"default_password": a.cfg.AdminPassword == "admin",
|
||||
})
|
||||
}
|
||||
|
||||
func (a *api) issue(user string) (string, time.Time, error) {
|
||||
now := time.Now()
|
||||
exp := now.Add(tokenTTL)
|
||||
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{
|
||||
Subject: user,
|
||||
Issuer: tokenIssuer,
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
NotBefore: jwt.NewNumericDate(now.Add(-time.Minute)),
|
||||
ExpiresAt: jwt.NewNumericDate(exp),
|
||||
})
|
||||
s, err := tok.SignedString(a.key())
|
||||
return s, exp, err
|
||||
}
|
||||
|
||||
// requireAuth guards every /api route except the login.
|
||||
func (a *api) requireAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
raw := bearerToken(r)
|
||||
if raw == "" {
|
||||
unauthorized(w, "missing bearer token")
|
||||
return
|
||||
}
|
||||
var claims jwt.RegisteredClaims
|
||||
_, err := jwt.ParseWithClaims(raw, &claims, func(*jwt.Token) (any, error) {
|
||||
return a.key(), nil
|
||||
},
|
||||
// Pinning the algorithm is what stops the classic "alg: none" and
|
||||
// HS256-signed-with-the-public-key forgeries.
|
||||
jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}),
|
||||
jwt.WithIssuer(tokenIssuer),
|
||||
)
|
||||
if err != nil {
|
||||
unauthorized(w, "invalid or expired token")
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), ctxKeyUser, claims.Subject)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
func unauthorized(w http.ResponseWriter, msg string) {
|
||||
w.Header().Set("WWW-Authenticate", `Bearer realm="prizma"`)
|
||||
writeErr(w, http.StatusUnauthorized, msg)
|
||||
}
|
||||
|
||||
func bearerToken(r *http.Request) string {
|
||||
h := r.Header.Get("Authorization")
|
||||
if len(h) < 7 || !strings.EqualFold(h[:7], "bearer ") {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(h[7:])
|
||||
}
|
||||
|
||||
// constantTimeEqual hashes first so that the comparison is over fixed-length
|
||||
// inputs: subtle.ConstantTimeCompare returns early on a length mismatch and
|
||||
// would otherwise leak the secret's length.
|
||||
func constantTimeEqual(got, want string) bool {
|
||||
g := sha256.Sum256([]byte(got))
|
||||
w := sha256.Sum256([]byte(want))
|
||||
return subtle.ConstantTimeCompare(g[:], w[:]) == 1
|
||||
}
|
||||
|
||||
// rateLimiter is a fixed-window counter of FAILED logins per client address.
|
||||
// Counting only failures means a busy legitimate admin is never locked out.
|
||||
type rateLimiter struct {
|
||||
mu sync.Mutex
|
||||
m map[string]*window
|
||||
max int
|
||||
period time.Duration
|
||||
}
|
||||
|
||||
type window struct {
|
||||
count int
|
||||
until time.Time
|
||||
}
|
||||
|
||||
func newRateLimiter(max int, period time.Duration) *rateLimiter {
|
||||
return &rateLimiter{m: make(map[string]*window), max: max, period: period}
|
||||
}
|
||||
|
||||
func (l *rateLimiter) allow(key string) bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
w, ok := l.m[key]
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if time.Now().After(w.until) {
|
||||
delete(l.m, key)
|
||||
return true
|
||||
}
|
||||
return w.count < l.max
|
||||
}
|
||||
|
||||
func (l *rateLimiter) fail(key string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
now := time.Now()
|
||||
w, ok := l.m[key]
|
||||
if !ok || now.After(w.until) {
|
||||
w = &window{until: now.Add(l.period)}
|
||||
l.m[key] = w
|
||||
}
|
||||
w.count++
|
||||
l.pruneLocked(now)
|
||||
}
|
||||
|
||||
func (l *rateLimiter) reset(key string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
delete(l.m, key)
|
||||
}
|
||||
|
||||
// pruneLocked keeps the map from growing without bound under a distributed
|
||||
// guessing attempt.
|
||||
func (l *rateLimiter) pruneLocked(now time.Time) {
|
||||
if len(l.m) < 1024 {
|
||||
return
|
||||
}
|
||||
for k, w := range l.m {
|
||||
if now.After(w.until) {
|
||||
delete(l.m, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/omar/prizma/internal/model"
|
||||
"github.com/omar/prizma/internal/store"
|
||||
)
|
||||
|
||||
type clientsResponse struct {
|
||||
Items []model.Client `json:"items"`
|
||||
Total int `json:"total"`
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
}
|
||||
|
||||
func (a *api) listClients(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
query := store.ClientQuery{
|
||||
Search: strings.TrimSpace(q.Get("search")),
|
||||
Banned: optBool(q.Get("banned")),
|
||||
Allowed: optBool(q.Get("allowed")),
|
||||
Limit: atoiDefault(q.Get("limit"), 100),
|
||||
Offset: atoiDefault(q.Get("offset"), 0),
|
||||
Sort: q.Get("sort"),
|
||||
}
|
||||
if query.Limit <= 0 || query.Limit > 500 {
|
||||
query.Limit = 100
|
||||
}
|
||||
if query.Offset < 0 {
|
||||
query.Offset = 0
|
||||
}
|
||||
|
||||
items, total, err := a.st.ListClients(query)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if items == nil {
|
||||
items = []model.Client{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, clientsResponse{Items: items, Total: total, Limit: query.Limit, Offset: query.Offset})
|
||||
}
|
||||
|
||||
func (a *api) banClient(w http.ResponseWriter, r *http.Request) { a.setBan(w, r, true) }
|
||||
func (a *api) unbanClient(w http.ResponseWriter, r *http.Request) { a.setBan(w, r, false) }
|
||||
func (a *api) allowClient(w http.ResponseWriter, r *http.Request) { a.setAllow(w, r, true) }
|
||||
func (a *api) disallowClient(w http.ResponseWriter, r *http.Request) { a.setAllow(w, r, false) }
|
||||
|
||||
func (a *api) setBan(w http.ResponseWriter, r *http.Request, banned bool) {
|
||||
id, ok := pathID(r)
|
||||
if !ok {
|
||||
writeErr(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
if err := a.st.SetClientBanned(id, banned); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
a.log.Info("client ban changed", "client_id", id, "banned", banned)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"id": id, "banned": banned})
|
||||
}
|
||||
|
||||
func (a *api) setAllow(w http.ResponseWriter, r *http.Request, allowed bool) {
|
||||
id, ok := pathID(r)
|
||||
if !ok {
|
||||
writeErr(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
if err := a.st.SetClientAllowed(id, allowed); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
// Allowing the first client flips the whole instance into whitelist mode;
|
||||
// say so in the log because it locks out everyone else at once.
|
||||
if n, err := a.st.WhitelistCount(); err == nil {
|
||||
a.log.Info("client whitelist changed", "client_id", id, "allowed", allowed, "whitelist_size", n)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"id": id, "allowed": allowed})
|
||||
}
|
||||
|
||||
func (a *api) labelClient(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := pathID(r)
|
||||
if !ok {
|
||||
writeErr(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Label string `json:"label"`
|
||||
}
|
||||
if err := decodeJSON(r, &body); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
label := strings.TrimSpace(body.Label)
|
||||
if len(label) > 200 {
|
||||
label = label[:200]
|
||||
}
|
||||
if err := a.st.SetClientLabel(id, label); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"id": id, "label": label})
|
||||
}
|
||||
|
||||
func (a *api) deleteClient(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := pathID(r)
|
||||
if !ok {
|
||||
writeErr(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
if err := a.st.DeleteClient(id); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
type accessResponse struct {
|
||||
WhitelistActive bool `json:"whitelist_active"`
|
||||
Whitelisted int `json:"whitelisted"`
|
||||
Banned int `json:"banned"`
|
||||
}
|
||||
|
||||
// handleAccess summarizes the gate so the UI can warn before the operator
|
||||
// whitelists a single device and locks everybody else out.
|
||||
func (a *api) handleAccess(w http.ResponseWriter, r *http.Request) {
|
||||
whitelisted, err := a.st.WhitelistCount()
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
stats, err := a.st.Stats()
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, accessResponse{
|
||||
WhitelistActive: whitelisted > 0,
|
||||
Whitelisted: whitelisted,
|
||||
Banned: stats.BannedClients,
|
||||
})
|
||||
}
|
||||
|
||||
// optBool maps an absent or empty query value to "no filter", which is
|
||||
// different from an explicit false.
|
||||
func optBool(s string) *bool {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "1", "true", "yes":
|
||||
v := true
|
||||
return &v
|
||||
case "0", "false", "no":
|
||||
v := false
|
||||
return &v
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func atoiDefault(s string, def int) int {
|
||||
if s == "" {
|
||||
return def
|
||||
}
|
||||
n, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/omar/prizma/internal/model"
|
||||
"github.com/omar/prizma/internal/store"
|
||||
"github.com/omar/prizma/internal/subfmt"
|
||||
)
|
||||
|
||||
func (a *api) listGroups(w http.ResponseWriter, r *http.Request) {
|
||||
list, err := a.st.ListGroups()
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if list == nil {
|
||||
list = []model.Group{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, list)
|
||||
}
|
||||
|
||||
func (a *api) getGroup(w http.ResponseWriter, r *http.Request) {
|
||||
g, ok := a.loadGroup(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, g)
|
||||
}
|
||||
|
||||
func (a *api) createGroup(w http.ResponseWriter, r *http.Request) {
|
||||
g := model.Group{Enabled: true}
|
||||
if err := decodeJSON(r, &g); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "invalid JSON body: "+err.Error())
|
||||
return
|
||||
}
|
||||
normalizeGroup(&g)
|
||||
if err := a.validateGroup(g); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if err := a.st.CreateGroup(&g); err != nil {
|
||||
storeErr(w, err, "no such group")
|
||||
return
|
||||
}
|
||||
a.log.Info("group created", "id", g.ID, "name", g.Name, "members", len(g.Members))
|
||||
writeJSON(w, http.StatusCreated, g)
|
||||
}
|
||||
|
||||
func (a *api) updateGroup(w http.ResponseWriter, r *http.Request) {
|
||||
g, ok := a.loadGroup(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, createdAt := g.ID, g.CreatedAt
|
||||
if err := decodeJSON(r, g); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "invalid JSON body: "+err.Error())
|
||||
return
|
||||
}
|
||||
g.ID, g.CreatedAt = id, createdAt
|
||||
normalizeGroup(g)
|
||||
if err := a.validateGroup(*g); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if err := a.st.UpdateGroup(g); err != nil {
|
||||
storeErr(w, err, "no such group")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, g)
|
||||
}
|
||||
|
||||
func (a *api) deleteGroup(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := pathID(r)
|
||||
if !ok {
|
||||
writeErr(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
if err := a.st.DeleteGroup(id); err != nil {
|
||||
storeErr(w, err, "no such group")
|
||||
return
|
||||
}
|
||||
a.log.Info("group deleted", "id", id)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
type previewResponse struct {
|
||||
Format string `json:"format"`
|
||||
Count int `json:"count"`
|
||||
Nodes []model.Node `json:"nodes"`
|
||||
Skipped []string `json:"skipped,omitempty"`
|
||||
}
|
||||
|
||||
// previewGroup runs the real assembly pipeline so what the operator sees is
|
||||
// what a client would get, minus the rendering step.
|
||||
func (a *api) previewGroup(w http.ResponseWriter, r *http.Request) {
|
||||
g, ok := a.loadGroup(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), a.upstreamTimeout()+5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
ua := r.URL.Query().Get("ua")
|
||||
asm := a.assembleGroup(ctx, *g, ua)
|
||||
if asm.Err != nil {
|
||||
writeErr(w, http.StatusBadRequest, asm.Err.Error())
|
||||
return
|
||||
}
|
||||
nodes := asm.Nodes
|
||||
if nodes == nil {
|
||||
nodes = []model.Node{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, previewResponse{
|
||||
Format: string(negotiateFormat(r, *g, ua)),
|
||||
Count: len(nodes),
|
||||
Nodes: nodes,
|
||||
Skipped: asm.Skipped,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *api) loadGroup(w http.ResponseWriter, r *http.Request) (*model.Group, bool) {
|
||||
id, ok := pathID(r)
|
||||
if !ok {
|
||||
writeErr(w, http.StatusBadRequest, "invalid id")
|
||||
return nil, false
|
||||
}
|
||||
g, err := a.st.GetGroup(id)
|
||||
if err != nil {
|
||||
storeErr(w, err, "no such group")
|
||||
return nil, false
|
||||
}
|
||||
return g, true
|
||||
}
|
||||
|
||||
func normalizeGroup(g *model.Group) {
|
||||
g.Name = strings.TrimSpace(g.Name)
|
||||
g.Token = strings.TrimSpace(g.Token)
|
||||
g.OutputFormat = strings.TrimSpace(strings.ToLower(g.OutputFormat))
|
||||
g.NameTemplate = strings.TrimSpace(g.NameTemplate)
|
||||
if g.Members == nil {
|
||||
g.Members = []model.GroupMember{}
|
||||
}
|
||||
for i := range g.Members {
|
||||
g.Members[i].Prefix = strings.TrimSpace(g.Members[i].Prefix)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *api) validateGroup(g model.Group) error {
|
||||
if g.Name == "" {
|
||||
return errors.New("name is required")
|
||||
}
|
||||
if g.Token != "" && !tokenPattern.MatchString(g.Token) {
|
||||
return errors.New("token must be 4-64 characters of A-Z a-z 0-9 _ -")
|
||||
}
|
||||
if g.OutputFormat != "" {
|
||||
if _, ok := subfmt.ParseFormat(g.OutputFormat); !ok {
|
||||
return fmt.Errorf("unknown output format %q", g.OutputFormat)
|
||||
}
|
||||
}
|
||||
if err := validateFilter("group filter", g.Filter); err != nil {
|
||||
return err
|
||||
}
|
||||
seen := make(map[int64]bool, len(g.Members))
|
||||
for i, m := range g.Members {
|
||||
if m.SourceID <= 0 {
|
||||
return fmt.Errorf("member %d has no source", i+1)
|
||||
}
|
||||
if seen[m.SourceID] {
|
||||
return fmt.Errorf("source %d is listed twice", m.SourceID)
|
||||
}
|
||||
seen[m.SourceID] = true
|
||||
|
||||
src, err := a.st.GetSource(m.SourceID)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return fmt.Errorf("member %d refers to a source that does not exist", i+1)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateFilter(fmt.Sprintf("member %q filter", src.Name), m.Filter); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateFilter compiles the patterns now so a typo is a 400 at save time
|
||||
// instead of a broken subscription discovered by a user later.
|
||||
func validateFilter(what string, f model.Filter) error {
|
||||
for _, p := range []struct {
|
||||
field string
|
||||
expr string
|
||||
}{
|
||||
{"include_name", f.IncludeName},
|
||||
{"exclude_name", f.ExcludeName},
|
||||
{"include_content", f.IncludeContent},
|
||||
{"exclude_content", f.ExcludeContent},
|
||||
} {
|
||||
if p.expr == "" {
|
||||
continue
|
||||
}
|
||||
expr := p.expr
|
||||
if !f.CaseSensitive {
|
||||
expr = "(?i)" + expr
|
||||
}
|
||||
if _, err := regexp.Compile(expr); err != nil {
|
||||
return fmt.Errorf("%s: %s is not a valid regular expression: %w", what, p.field, err)
|
||||
}
|
||||
}
|
||||
if f.Limit < 0 {
|
||||
return fmt.Errorf("%s: limit cannot be negative", what)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
// Package api is Prizma's HTTP surface: the authenticated admin REST API and
|
||||
// the public subscription endpoint.
|
||||
//
|
||||
// The two halves have opposite trust models. Everything under /api is behind a
|
||||
// bearer token and speaks JSON. /sub/{token} is wide open by design — that is
|
||||
// the link users paste into their client — and its job is to be as transparent
|
||||
// a pipe as possible: for a source link the upstream body goes out
|
||||
// byte-for-byte, with the upstream's own headers, minus the HWID state that
|
||||
// must never leak back to the clients.
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
"github.com/omar/prizma/internal/config"
|
||||
"github.com/omar/prizma/internal/store"
|
||||
"github.com/omar/prizma/internal/subfmt"
|
||||
"github.com/omar/prizma/internal/upstream"
|
||||
)
|
||||
|
||||
// Deps is everything the HTTP layer needs; it owns none of it.
|
||||
type Deps struct {
|
||||
Cfg config.Config
|
||||
Store *store.Store
|
||||
Fetch *upstream.Fetcher
|
||||
}
|
||||
|
||||
type api struct {
|
||||
cfg config.Config
|
||||
st *store.Store
|
||||
fetch *upstream.Fetcher
|
||||
log *slog.Logger
|
||||
|
||||
// The signing key is resolved lazily: newAPI also builds the public /sub
|
||||
// handler, which has no business generating or persisting a secret.
|
||||
secretOnce sync.Once
|
||||
secret []byte
|
||||
limiter *rateLimiter
|
||||
}
|
||||
|
||||
func newAPI(deps Deps) *api {
|
||||
return &api{
|
||||
cfg: deps.Cfg,
|
||||
st: deps.Store,
|
||||
fetch: deps.Fetch,
|
||||
log: slog.Default(),
|
||||
limiter: newRateLimiter(loginAttempts, loginWindow),
|
||||
}
|
||||
}
|
||||
|
||||
// Router returns the admin API. It is written as a subtree and is meant to be
|
||||
// mounted at /api by cmd/prizma; every route except the login is behind the
|
||||
// bearer middleware.
|
||||
func Router(deps Deps) http.Handler {
|
||||
a := newAPI(deps)
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.CleanPath)
|
||||
|
||||
r.Post("/auth/login", a.handleLogin)
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(a.requireAuth)
|
||||
|
||||
r.Get("/auth/me", a.handleMe)
|
||||
r.Get("/stats", a.handleStats)
|
||||
r.Get("/formats", a.handleFormats)
|
||||
r.Get("/access", a.handleAccess)
|
||||
|
||||
r.Route("/sources", func(r chi.Router) {
|
||||
r.Get("/", a.listSources)
|
||||
r.Post("/", a.createSource)
|
||||
r.Get("/{id}", a.getSource)
|
||||
r.Put("/{id}", a.updateSource)
|
||||
r.Delete("/{id}", a.deleteSource)
|
||||
r.Post("/{id}/test", a.testSource)
|
||||
r.Post("/{id}/rotate-hwid", a.rotateSourceHWID)
|
||||
})
|
||||
|
||||
r.Route("/groups", func(r chi.Router) {
|
||||
r.Get("/", a.listGroups)
|
||||
r.Post("/", a.createGroup)
|
||||
r.Get("/{id}", a.getGroup)
|
||||
r.Put("/{id}", a.updateGroup)
|
||||
r.Delete("/{id}", a.deleteGroup)
|
||||
r.Post("/{id}/preview", a.previewGroup)
|
||||
})
|
||||
|
||||
r.Route("/clients", func(r chi.Router) {
|
||||
r.Get("/", a.listClients)
|
||||
r.Post("/{id}/ban", a.banClient)
|
||||
r.Post("/{id}/unban", a.unbanClient)
|
||||
r.Post("/{id}/allow", a.allowClient)
|
||||
r.Post("/{id}/disallow", a.disallowClient)
|
||||
r.Post("/{id}/label", a.labelClient)
|
||||
r.Delete("/{id}", a.deleteClient)
|
||||
})
|
||||
})
|
||||
|
||||
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, http.StatusNotFound, "no such endpoint")
|
||||
})
|
||||
r.MethodNotAllowed(func(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// Health answers the liveness probe. Deliberately unauthenticated and free of
|
||||
// any dependency that could make it hang.
|
||||
func Health() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if r.Method != http.MethodHead {
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *api) handleStats(w http.ResponseWriter, r *http.Request) {
|
||||
st, err := a.st.Stats()
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, st)
|
||||
}
|
||||
|
||||
type formatInfo struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
ContentType string `json:"content_type"`
|
||||
}
|
||||
|
||||
// formatLabels are the human names the UI shows. Driven off subfmt.AllFormats
|
||||
// so a format added there cannot go missing from the dropdown.
|
||||
var formatLabels = map[subfmt.Format]string{
|
||||
subfmt.FormatBase64: "Base64 (v2rayN, Streisand, Shadowrocket)",
|
||||
subfmt.FormatURIList: "Plain URI list",
|
||||
subfmt.FormatClash: "Clash / Mihomo YAML",
|
||||
subfmt.FormatSingBox: "sing-box JSON",
|
||||
subfmt.FormatXray: "Xray / Happ JSON",
|
||||
}
|
||||
|
||||
func (a *api) handleFormats(w http.ResponseWriter, r *http.Request) {
|
||||
all := subfmt.AllFormats()
|
||||
out := make([]formatInfo, 0, len(all))
|
||||
for _, f := range all {
|
||||
label := formatLabels[f]
|
||||
if label == "" {
|
||||
label = f.String()
|
||||
}
|
||||
out = append(out, formatInfo{ID: f.String(), Label: label, ContentType: subfmt.ContentTypeFor(f)})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// ---- small shared helpers -------------------------------------------------
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
buf, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"encode failure"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write(buf)
|
||||
}
|
||||
|
||||
func writeErr(w http.ResponseWriter, status int, msg string) {
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
// storeErr maps the store's sentinel errors onto HTTP status codes so every
|
||||
// handler reports "gone" and "token taken" the same way.
|
||||
func storeErr(w http.ResponseWriter, err error, notFound string) {
|
||||
switch {
|
||||
case errors.Is(err, store.ErrNotFound):
|
||||
writeErr(w, http.StatusNotFound, notFound)
|
||||
case errors.Is(err, store.ErrTokenTaken):
|
||||
writeErr(w, http.StatusConflict, "that link token is already in use")
|
||||
default:
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// decodeJSON rejects unknown fields so a typo in the admin UI surfaces as a
|
||||
// 400 instead of silently doing nothing.
|
||||
func decodeJSON(r *http.Request, dst any) error {
|
||||
dec := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
|
||||
dec.DisallowUnknownFields()
|
||||
return dec.Decode(dst)
|
||||
}
|
||||
|
||||
func pathID(r *http.Request) (int64, bool) {
|
||||
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
// clientIP resolves the caller address, honouring X-Forwarded-For only when the
|
||||
// operator said there is a trusted proxy in front — otherwise any client could
|
||||
// forge its identity and dodge a ban or a rate limit.
|
||||
func clientIP(r *http.Request, trustProxy bool) string {
|
||||
if trustProxy {
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
if first := strings.TrimSpace(strings.Split(xff, ",")[0]); first != "" {
|
||||
return first
|
||||
}
|
||||
}
|
||||
if rip := strings.TrimSpace(r.Header.Get("X-Real-IP")); rip != "" {
|
||||
return rip
|
||||
}
|
||||
}
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
return host
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/omar/prizma/internal/hwid"
|
||||
"github.com/omar/prizma/internal/model"
|
||||
"github.com/omar/prizma/internal/subfmt"
|
||||
)
|
||||
|
||||
// tokenPattern is the charset a public link slug may use. It has to survive
|
||||
// being pasted into a QR code, a shell and a client's URL parser.
|
||||
var tokenPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{4,64}$`)
|
||||
|
||||
func (a *api) listSources(w http.ResponseWriter, r *http.Request) {
|
||||
list, err := a.st.ListSources()
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if list == nil {
|
||||
list = []model.Source{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, list)
|
||||
}
|
||||
|
||||
func (a *api) getSource(w http.ResponseWriter, r *http.Request) {
|
||||
src, ok := a.loadSource(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, src)
|
||||
}
|
||||
|
||||
func (a *api) createSource(w http.ResponseWriter, r *http.Request) {
|
||||
// Enabled defaults to true: a source nobody can use is never what the
|
||||
// operator meant when they typed in a URL.
|
||||
src := model.Source{Enabled: true}
|
||||
if err := decodeJSON(r, &src); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "invalid JSON body: "+err.Error())
|
||||
return
|
||||
}
|
||||
normalizeSource(&src)
|
||||
if err := validateSource(src); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if err := a.st.CreateSource(&src); err != nil {
|
||||
storeErr(w, err, "no such source")
|
||||
return
|
||||
}
|
||||
a.log.Info("source created", "id", src.ID, "name", src.Name)
|
||||
writeJSON(w, http.StatusCreated, src)
|
||||
}
|
||||
|
||||
func (a *api) updateSource(w http.ResponseWriter, r *http.Request) {
|
||||
src, ok := a.loadSource(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, createdAt := src.ID, src.CreatedAt
|
||||
|
||||
// Decoding onto the loaded record means an omitted field keeps its value —
|
||||
// notably HWID, which must survive an edit of the name or the URL.
|
||||
if err := decodeJSON(r, src); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "invalid JSON body: "+err.Error())
|
||||
return
|
||||
}
|
||||
src.ID, src.CreatedAt = id, createdAt
|
||||
normalizeSource(src)
|
||||
if err := validateSource(*src); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if err := a.st.UpdateSource(src); err != nil {
|
||||
storeErr(w, err, "no such source")
|
||||
return
|
||||
}
|
||||
// The URL, headers or HWID may have changed; anything cached under the old
|
||||
// identity is now a lie.
|
||||
a.fetch.Invalidate(src.ID)
|
||||
writeJSON(w, http.StatusOK, src)
|
||||
}
|
||||
|
||||
func (a *api) deleteSource(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := pathID(r)
|
||||
if !ok {
|
||||
writeErr(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
if err := a.st.DeleteSource(id); err != nil {
|
||||
storeErr(w, err, "no such source")
|
||||
return
|
||||
}
|
||||
a.fetch.Invalidate(id)
|
||||
a.log.Info("source deleted", "id", id)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (a *api) rotateSourceHWID(w http.ResponseWriter, r *http.Request) {
|
||||
src, ok := a.loadSource(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
src.HWID = hwid.New()
|
||||
if err := a.st.UpdateSource(src); err != nil {
|
||||
storeErr(w, err, "no such source")
|
||||
return
|
||||
}
|
||||
a.fetch.Invalidate(src.ID)
|
||||
a.log.Info("source hwid rotated", "id", src.ID, "name", src.Name)
|
||||
writeJSON(w, http.StatusOK, src)
|
||||
}
|
||||
|
||||
type testResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
Format string `json:"format"`
|
||||
Nodes []model.Node `json:"nodes"`
|
||||
Count int `json:"count"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// testSource performs a live fetch, bypassing the cache, so the operator sees
|
||||
// what the upstream answers right now and not what it answered ten minutes ago.
|
||||
func (a *api) testSource(w http.ResponseWriter, r *http.Request) {
|
||||
src, ok := a.loadSource(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
a.fetch.Invalidate(src.ID)
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), a.upstreamTimeout())
|
||||
defer cancel()
|
||||
|
||||
nodes, format, err := a.fetch.Nodes(ctx, *src, r.URL.Query().Get("ua"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusOK, testResponse{Format: string(subfmt.FormatUnknown), Nodes: []model.Node{}, Error: err.Error()})
|
||||
return
|
||||
}
|
||||
if nodes == nil {
|
||||
nodes = []model.Node{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, testResponse{OK: true, Format: string(format), Nodes: nodes, Count: len(nodes)})
|
||||
}
|
||||
|
||||
func (a *api) loadSource(w http.ResponseWriter, r *http.Request) (*model.Source, bool) {
|
||||
id, ok := pathID(r)
|
||||
if !ok {
|
||||
writeErr(w, http.StatusBadRequest, "invalid id")
|
||||
return nil, false
|
||||
}
|
||||
src, err := a.st.GetSource(id)
|
||||
if err != nil {
|
||||
storeErr(w, err, "no such source")
|
||||
return nil, false
|
||||
}
|
||||
return src, true
|
||||
}
|
||||
|
||||
func (a *api) upstreamTimeout() time.Duration {
|
||||
if a.cfg.UpstreamTimeout > 0 {
|
||||
return a.cfg.UpstreamTimeout
|
||||
}
|
||||
return 20 * time.Second
|
||||
}
|
||||
|
||||
func normalizeSource(s *model.Source) {
|
||||
s.Name = strings.TrimSpace(s.Name)
|
||||
s.URL = strings.TrimSpace(s.URL)
|
||||
s.Token = strings.TrimSpace(s.Token)
|
||||
s.HWID = strings.TrimSpace(s.HWID)
|
||||
s.UserAgent = strings.TrimSpace(s.UserAgent)
|
||||
if s.CacheTTL < 0 {
|
||||
s.CacheTTL = 0
|
||||
}
|
||||
if len(s.ExtraHeaders) == 0 {
|
||||
s.ExtraHeaders = nil
|
||||
return
|
||||
}
|
||||
clean := make(map[string]string, len(s.ExtraHeaders))
|
||||
for k, v := range s.ExtraHeaders {
|
||||
if k = strings.TrimSpace(k); k != "" {
|
||||
clean[k] = strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
s.ExtraHeaders = clean
|
||||
}
|
||||
|
||||
func validateSource(s model.Source) error {
|
||||
if s.Name == "" {
|
||||
return errors.New("name is required")
|
||||
}
|
||||
if err := validateUpstreamURL(s.URL); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.Token != "" && !tokenPattern.MatchString(s.Token) {
|
||||
return errors.New("token must be 4-64 characters of A-Z a-z 0-9 _ -")
|
||||
}
|
||||
for k := range s.ExtraHeaders {
|
||||
// A newline in a header name or value is a request-splitting primitive.
|
||||
if strings.ContainsAny(k, "\r\n") || strings.ContainsAny(s.ExtraHeaders[k], "\r\n") {
|
||||
return fmt.Errorf("extra header %q contains a line break", k)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateUpstreamURL(raw string) error {
|
||||
if raw == "" {
|
||||
return errors.New("url is required")
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("url is not valid: %w", err)
|
||||
}
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return errors.New("url must be http:// or https://")
|
||||
}
|
||||
if u.Host == "" {
|
||||
return errors.New("url has no host")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/omar/prizma/internal/hwid"
|
||||
"github.com/omar/prizma/internal/merge"
|
||||
"github.com/omar/prizma/internal/model"
|
||||
"github.com/omar/prizma/internal/store"
|
||||
"github.com/omar/prizma/internal/subfmt"
|
||||
)
|
||||
|
||||
// maxParallelMembers bounds how many upstreams one group link opens at once. A
|
||||
// group with fifty members must not turn a single client request into fifty
|
||||
// simultaneous outbound connections.
|
||||
const maxParallelMembers = 8
|
||||
|
||||
// groupUpdateInterval is the profile-update-interval (in hours) advertised on
|
||||
// group links. Source links inherit whatever the upstream advertises instead.
|
||||
const groupUpdateInterval = 12
|
||||
|
||||
// SubscribeHandler returns the public subscription subtree, meant to be mounted
|
||||
// at /sub by cmd/prizma. It is unauthenticated on purpose: this is the link a
|
||||
// user pastes into their client.
|
||||
func SubscribeHandler(deps Deps) http.Handler {
|
||||
a := newAPI(deps)
|
||||
r := chi.NewRouter()
|
||||
for _, pattern := range []string{"/{token}", "/{token}/*"} {
|
||||
r.Get(pattern, a.handleSubscribe)
|
||||
r.Head(pattern, a.handleSubscribe)
|
||||
}
|
||||
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
func (a *api) handleSubscribe(w http.ResponseWriter, r *http.Request) {
|
||||
token := strings.TrimSpace(chi.URLParam(r, "token"))
|
||||
src, group, err := a.resolveToken(token)
|
||||
if err != nil {
|
||||
// A wrong token and a disabled link are indistinguishable from outside;
|
||||
// that is deliberate, there is nothing to enumerate here.
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
linkRef, linkName := "", ""
|
||||
if src != nil {
|
||||
linkRef, linkName = "source:"+strconv.FormatInt(src.ID, 10), src.Name
|
||||
} else {
|
||||
linkRef, linkName = "group:"+strconv.FormatInt(group.ID, 10), group.Name
|
||||
}
|
||||
|
||||
clientHWID, synthetic := hwid.FromRequest(r)
|
||||
client := model.Client{
|
||||
HWID: clientHWID,
|
||||
Synthetic: synthetic,
|
||||
UserAgent: r.UserAgent(),
|
||||
IP: clientIP(r, a.cfg.TrustProxy),
|
||||
DeviceOS: r.Header.Get("x-device-os"),
|
||||
LastLink: linkRef,
|
||||
}
|
||||
|
||||
// The device is recorded whatever the outcome — including a 403. An
|
||||
// operator who just turned the whitelist on needs to SEE the devices that
|
||||
// are being turned away in order to allow them.
|
||||
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
|
||||
defer func() {
|
||||
client.LastStatus = rec.status
|
||||
if _, err := a.st.TouchClient(client); err != nil {
|
||||
a.log.Warn("touch client", "hwid", client.HWID, "err", err)
|
||||
}
|
||||
if err := a.st.LogRequest(linkRef, client.HWID, rec.status); err != nil {
|
||||
a.log.Warn("log request", "link", linkRef, "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
if dec := hwid.Gate(a.st, &client); !dec.Allowed {
|
||||
a.log.Info("subscription denied", "link", linkRef, "hwid", client.HWID, "reason", dec.Reason)
|
||||
http.Error(rec, "forbidden: "+dec.Reason, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if src != nil {
|
||||
a.serveSource(rec, r, *src)
|
||||
return
|
||||
}
|
||||
a.serveGroup(rec, r, *group, linkName)
|
||||
}
|
||||
|
||||
// resolveToken finds the enabled source or group a public slug points at.
|
||||
// Clients that append a fake extension ("…/sub/abcd.yaml") are tolerated.
|
||||
func (a *api) resolveToken(token string) (*model.Source, *model.Group, error) {
|
||||
candidates := []string{token}
|
||||
if i := strings.LastIndexByte(token, '.'); i > 0 {
|
||||
candidates = append(candidates, token[:i])
|
||||
}
|
||||
|
||||
for _, tok := range candidates {
|
||||
if tok == "" {
|
||||
continue
|
||||
}
|
||||
switch src, err := a.st.GetSourceByToken(tok); {
|
||||
case err == nil && src.Enabled:
|
||||
return src, nil, nil
|
||||
case err == nil:
|
||||
return nil, nil, errors.New("link disabled")
|
||||
case !errors.Is(err, store.ErrNotFound):
|
||||
return nil, nil, err
|
||||
}
|
||||
switch g, err := a.st.GetGroupByToken(tok); {
|
||||
case err == nil && g.Enabled:
|
||||
return nil, g, nil
|
||||
case err == nil:
|
||||
return nil, nil, errors.New("link disabled")
|
||||
case !errors.Is(err, store.ErrNotFound):
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
return nil, nil, store.ErrNotFound
|
||||
}
|
||||
|
||||
// serveSource is the byte-exact proxy path. Whatever the upstream said, the
|
||||
// client gets — same bytes, same headers — with the source's HWID swapped in on
|
||||
// the way out and the upstream's HWID state stripped on the way back.
|
||||
func (a *api) serveSource(w http.ResponseWriter, r *http.Request, src model.Source) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), a.upstreamTimeout())
|
||||
defer cancel()
|
||||
|
||||
res, err := a.fetch.Fetch(ctx, src, r.UserAgent(), r.Header)
|
||||
if err != nil {
|
||||
a.log.Error("upstream fetch failed", "source", src.Name, "id", src.ID, "err", err)
|
||||
http.Error(w, "upstream unavailable", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
copyDownstreamHeaders(w.Header(), res.Header)
|
||||
if res.ContentType != "" {
|
||||
w.Header().Set("Content-Type", res.ContentType)
|
||||
}
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(res.Body)))
|
||||
if res.FromCache {
|
||||
w.Header().Set("X-Prizma-Cache", "hit")
|
||||
}
|
||||
|
||||
w.WriteHeader(res.Status)
|
||||
if r.Method == http.MethodHead {
|
||||
return
|
||||
}
|
||||
if _, err := w.Write(res.Body); err != nil {
|
||||
a.log.Debug("client hung up mid-body", "source", src.Name, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// serveGroup fetches every member, merges, and renders in the negotiated
|
||||
// format. The body is synthesized here, so unlike a source link there is no
|
||||
// upstream header set to pass through.
|
||||
func (a *api) serveGroup(w http.ResponseWriter, r *http.Request, g model.Group, title string) {
|
||||
ua := r.UserAgent()
|
||||
format := negotiateFormat(r, g, ua)
|
||||
|
||||
// The context covers the whole fan-out; each member additionally gets its
|
||||
// own timeout so one slow upstream cannot eat the entire budget.
|
||||
ctx, cancel := context.WithTimeout(r.Context(), a.upstreamTimeout()+5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
asm := a.assembleGroup(ctx, g, ua)
|
||||
for _, s := range asm.Skipped {
|
||||
a.log.Warn("group member skipped", "group", g.Name, "detail", s)
|
||||
}
|
||||
if asm.Err != nil {
|
||||
a.log.Error("group merge failed", "group", g.Name, "err", asm.Err)
|
||||
http.Error(w, "group configuration is broken", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// One dead member must not take the link down; all of them dead means we
|
||||
// have nothing truthful to say, and answering an empty subscription would
|
||||
// make clients wipe their working server list.
|
||||
if asm.Attempted > 0 && asm.Failed == asm.Attempted {
|
||||
http.Error(w, "every upstream in this group failed", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
body, contentType, err := subfmt.Render(asm.Nodes, format, subfmt.RenderOpts{ProfileTitle: title})
|
||||
if err != nil {
|
||||
a.log.Error("group render failed", "group", g.Name, "format", format, "err", err)
|
||||
http.Error(w, "cannot render this subscription format", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if contentType == "" {
|
||||
contentType = subfmt.ContentTypeFor(format)
|
||||
}
|
||||
|
||||
h := w.Header()
|
||||
h.Set("Content-Type", contentType)
|
||||
h.Set("Content-Length", strconv.Itoa(len(body)))
|
||||
h.Set("Cache-Control", "no-store")
|
||||
// The headers clients actually read off a subscription response.
|
||||
h.Set("profile-title", "base64:"+base64.StdEncoding.EncodeToString([]byte(title)))
|
||||
h.Set("profile-update-interval", strconv.Itoa(groupUpdateInterval))
|
||||
if a.cfg.BaseURL != "" {
|
||||
h.Set("profile-web-page-url", a.cfg.BaseURL)
|
||||
}
|
||||
h.Set("Content-Disposition", "attachment; filename*=UTF-8''"+url.PathEscape(title))
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if r.Method == http.MethodHead {
|
||||
return
|
||||
}
|
||||
if _, err := w.Write(body); err != nil {
|
||||
a.log.Debug("client hung up mid-body", "group", g.Name, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// groupAssembly is the outcome of fetching and merging a group's members.
|
||||
type groupAssembly struct {
|
||||
Nodes []model.Node
|
||||
Skipped []string // human-readable reasons, surfaced in the preview UI
|
||||
Attempted int // members we actually tried to fetch
|
||||
Failed int // of those, the ones that did not answer
|
||||
Err error // a broken filter — fatal, unlike a dead member
|
||||
}
|
||||
|
||||
func (a *api) assembleGroup(ctx context.Context, g model.Group, clientUA string) groupAssembly {
|
||||
var asm groupAssembly
|
||||
|
||||
sources := make([]*model.Source, len(g.Members))
|
||||
for i, m := range g.Members {
|
||||
src, err := a.st.GetSource(m.SourceID)
|
||||
switch {
|
||||
case errors.Is(err, store.ErrNotFound):
|
||||
asm.Skipped = append(asm.Skipped, fmt.Sprintf("member %d: source %d no longer exists", i+1, m.SourceID))
|
||||
case err != nil:
|
||||
asm.Skipped = append(asm.Skipped, fmt.Sprintf("member %d: %v", i+1, err))
|
||||
case !src.Enabled:
|
||||
asm.Skipped = append(asm.Skipped, fmt.Sprintf("%s: disabled", src.Name))
|
||||
default:
|
||||
sources[i] = src
|
||||
asm.Attempted++
|
||||
}
|
||||
}
|
||||
|
||||
lists := make([][]model.Node, len(g.Members))
|
||||
sem := make(chan struct{}, maxParallelMembers)
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
|
||||
for i, src := range sources {
|
||||
if src == nil {
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
go func(i int, src model.Source) {
|
||||
defer wg.Done()
|
||||
sem <- struct{}{}
|
||||
defer func() { <-sem }()
|
||||
|
||||
mctx, cancel := context.WithTimeout(ctx, a.upstreamTimeout())
|
||||
defer cancel()
|
||||
|
||||
nodes, _, err := a.fetch.Nodes(mctx, src, clientUA)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if err != nil {
|
||||
asm.Failed++
|
||||
asm.Skipped = append(asm.Skipped, src.Name+": "+err.Error())
|
||||
return
|
||||
}
|
||||
lists[i] = nodes
|
||||
}(i, *src)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// merge.Group reads provenance off the member records; fill in the source
|
||||
// names so {source} in a NameTemplate resolves even when the stored group
|
||||
// was written before the source was renamed.
|
||||
named := g
|
||||
named.Members = append([]model.GroupMember(nil), g.Members...)
|
||||
for i := range named.Members {
|
||||
if sources[i] != nil && named.Members[i].Name == "" {
|
||||
named.Members[i].Name = sources[i].Name
|
||||
}
|
||||
}
|
||||
|
||||
nodes, err := merge.Group(lists, named)
|
||||
asm.Nodes, asm.Err = nodes, err
|
||||
return asm
|
||||
}
|
||||
|
||||
// negotiateFormat: an explicit ?format= wins, then the group's pinned output
|
||||
// format, then whatever the client's User-Agent implies.
|
||||
func negotiateFormat(r *http.Request, g model.Group, ua string) subfmt.Format {
|
||||
if q := strings.TrimSpace(r.URL.Query().Get("format")); q != "" {
|
||||
if f, ok := subfmt.ParseFormat(q); ok {
|
||||
return f
|
||||
}
|
||||
}
|
||||
if g.OutputFormat != "" {
|
||||
if f, ok := subfmt.ParseFormat(g.OutputFormat); ok {
|
||||
return f
|
||||
}
|
||||
}
|
||||
return subfmt.FormatForUA(ua)
|
||||
}
|
||||
|
||||
// hopByHopDownstream mirrors the request-side list: these describe a single
|
||||
// connection and must never be relayed.
|
||||
var hopByHopDownstream = map[string]bool{
|
||||
"connection": true,
|
||||
"keep-alive": true,
|
||||
"transfer-encoding": true,
|
||||
"upgrade": true,
|
||||
"trailer": true,
|
||||
"te": true,
|
||||
"proxy-authenticate": true,
|
||||
"proxy-authorization": true,
|
||||
"proxy-connection": true,
|
||||
|
||||
// Recomputed: the body we write is the decoded body, and its length is
|
||||
// whatever we ended up with.
|
||||
"content-length": true,
|
||||
"content-encoding": true,
|
||||
}
|
||||
|
||||
// copyDownstreamHeaders relays the upstream's response headers to the client.
|
||||
//
|
||||
// The one thing that must NOT come back is the upstream's HWID state. Prizma's
|
||||
// whole purpose is that one pinned upstream device serves many clients; if the
|
||||
// upstream's x-hwid (or any vendor's hwid-flavoured header) reached those
|
||||
// clients, the pinned identity would leak to every device on the link.
|
||||
func copyDownstreamHeaders(dst, upstream http.Header) {
|
||||
for k, values := range upstream {
|
||||
lower := strings.ToLower(k)
|
||||
if hopByHopDownstream[lower] || strings.HasPrefix(lower, "proxy-") {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(lower, "hwid") {
|
||||
continue
|
||||
}
|
||||
for _, v := range values {
|
||||
dst.Add(k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// statusRecorder remembers the status code so the access log and the client
|
||||
// record show what the device actually received.
|
||||
type statusRecorder struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
written bool
|
||||
}
|
||||
|
||||
func (s *statusRecorder) WriteHeader(code int) {
|
||||
if s.written {
|
||||
return
|
||||
}
|
||||
s.written = true
|
||||
s.status = code
|
||||
s.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (s *statusRecorder) Write(b []byte) (int, error) {
|
||||
if !s.written {
|
||||
s.written = true
|
||||
}
|
||||
return s.ResponseWriter.Write(b)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// Package config loads Prizma's runtime configuration from the environment.
|
||||
//
|
||||
// Every knob has a working default so that `prizma` started with no
|
||||
// environment at all comes up on :8080 with a SQLite file under data/.
|
||||
package config
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config is the process-wide configuration. It is passed by value; nothing
|
||||
// here is mutated after Load returns.
|
||||
type Config struct {
|
||||
Addr string // PRIZMA_ADDR, default ":8080"
|
||||
DBPath string // PRIZMA_DB, default "data/prizma.db"
|
||||
AdminUser string // ADMIN_USER, default "admin"
|
||||
AdminPassword string // ADMIN_PASSWORD, default "admin" (warns loudly)
|
||||
JWTSecret string // JWT_SECRET, generated when empty
|
||||
BaseURL string // PUBLIC_URL, used to print links in the UI
|
||||
CacheTTL time.Duration // CACHE_TTL seconds, default 300
|
||||
UpstreamTimeout time.Duration // UPSTREAM_TIMEOUT seconds, default 20
|
||||
LogLevel string // LOG_LEVEL
|
||||
TrustProxy bool // TRUST_PROXY, honour X-Forwarded-For
|
||||
}
|
||||
|
||||
// Load reads the environment and applies defaults.
|
||||
//
|
||||
// It never fails: a malformed numeric value falls back to the default rather
|
||||
// than taking the panel down, but it is reported so the operator can see it.
|
||||
func Load() Config {
|
||||
cfg := Config{
|
||||
Addr: env("PRIZMA_ADDR", ":8080"),
|
||||
DBPath: env("PRIZMA_DB", "data/prizma.db"),
|
||||
AdminUser: env("ADMIN_USER", "admin"),
|
||||
AdminPassword: env("ADMIN_PASSWORD", "admin"),
|
||||
JWTSecret: strings.TrimSpace(os.Getenv("JWT_SECRET")),
|
||||
BaseURL: strings.TrimRight(strings.TrimSpace(os.Getenv("PUBLIC_URL")), "/"),
|
||||
CacheTTL: envDuration("CACHE_TTL", 300*time.Second),
|
||||
UpstreamTimeout: envDuration("UPSTREAM_TIMEOUT", 20*time.Second),
|
||||
LogLevel: strings.ToLower(env("LOG_LEVEL", "info")),
|
||||
TrustProxy: envBool("TRUST_PROXY", false),
|
||||
}
|
||||
|
||||
if cfg.Addr != "" && !strings.Contains(cfg.Addr, ":") {
|
||||
// "8080" is the mistake everybody makes; net.Listen would reject it.
|
||||
cfg.Addr = ":" + cfg.Addr
|
||||
}
|
||||
if cfg.AdminPassword == "" {
|
||||
cfg.AdminPassword = "admin"
|
||||
}
|
||||
if cfg.AdminPassword == "admin" {
|
||||
log.Printf("WARNING: ADMIN_PASSWORD is unset — the admin panel accepts the default password %q. Set ADMIN_PASSWORD before exposing Prizma.", "admin")
|
||||
}
|
||||
if cfg.CacheTTL < 0 {
|
||||
cfg.CacheTTL = 0
|
||||
}
|
||||
if cfg.UpstreamTimeout <= 0 {
|
||||
cfg.UpstreamTimeout = 20 * time.Second
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func env(key, def string) string {
|
||||
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// envDuration accepts both a bare number of seconds ("300") and a Go duration
|
||||
// ("5m") so operators can write whichever feels natural.
|
||||
func envDuration(key string, def time.Duration) time.Duration {
|
||||
raw := strings.TrimSpace(os.Getenv(key))
|
||||
if raw == "" {
|
||||
return def
|
||||
}
|
||||
if n, err := strconv.ParseFloat(raw, 64); err == nil {
|
||||
return time.Duration(n * float64(time.Second))
|
||||
}
|
||||
if d, err := time.ParseDuration(raw); err == nil {
|
||||
return d
|
||||
}
|
||||
log.Printf("config: %s=%q is not a number of seconds nor a duration, using %s", key, raw, def)
|
||||
return def
|
||||
}
|
||||
|
||||
func envBool(key string, def bool) bool {
|
||||
raw := strings.ToLower(strings.TrimSpace(os.Getenv(key)))
|
||||
if raw == "" {
|
||||
return def
|
||||
}
|
||||
switch raw {
|
||||
case "1", "true", "yes", "y", "on":
|
||||
return true
|
||||
case "0", "false", "no", "n", "off":
|
||||
return false
|
||||
}
|
||||
log.Printf("config: %s=%q is not a boolean, using %v", key, raw, def)
|
||||
return def
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
// Package hwid generates hardware IDs and enforces Prizma's access gate.
|
||||
//
|
||||
// Two very different HWIDs meet here:
|
||||
//
|
||||
// - the SOURCE hwid, which Prizma presents to the upstream panel so the
|
||||
// upstream sees one pinned device (stored on model.Source),
|
||||
// - the CLIENT hwid, which a device presents to Prizma. Prizma does not
|
||||
// restrict it — that is the whole point — but it is recorded, and the
|
||||
// operator may ban a device or switch the link to a whitelist.
|
||||
//
|
||||
// Gate is the second one's enforcement point.
|
||||
package hwid
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/omar/prizma/internal/model"
|
||||
"github.com/omar/prizma/internal/store"
|
||||
)
|
||||
|
||||
// Length is the character length of a generated HWID.
|
||||
const Length = 32
|
||||
|
||||
// New returns a fresh 32-char lowercase hex HWID (the shape Remnawave and Happ
|
||||
// use).
|
||||
func New() string {
|
||||
b := make([]byte, Length/2)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
// crypto/rand failing means the OS entropy source is gone; there is no
|
||||
// sane way to continue handing out identities.
|
||||
panic("hwid: crypto/rand failed: " + err.Error())
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// Normalize trims and lower-cases a client-supplied HWID and drops anything
|
||||
// that is not printable. Case folding matters: without it a banned device
|
||||
// could return as "AB12…" and slip past a ban recorded as "ab12…".
|
||||
func Normalize(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
for _, r := range s {
|
||||
if r < 0x20 || r == 0x7f {
|
||||
continue
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
out := strings.ToLower(strings.TrimSpace(b.String()))
|
||||
if len(out) > 128 {
|
||||
out = out[:128]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// IsHex reports whether s has the canonical 32-char lowercase hex shape.
|
||||
func IsHex(s string) bool {
|
||||
if len(s) != Length {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// FromRequest extracts the client HWID: the x-hwid header, then the ?hwid=
|
||||
// query parameter, then a synthetic sha1(ua|ip)[:32] fingerprint.
|
||||
//
|
||||
// synthetic is true for that last case: the device sent no identity of its
|
||||
// own, so the value is a best-effort guess and several devices behind one NAT
|
||||
// with the same app version WILL share it.
|
||||
func FromRequest(r *http.Request) (id string, synthetic bool) {
|
||||
if r == nil {
|
||||
return "", true
|
||||
}
|
||||
if v := Normalize(r.Header.Get("x-hwid")); v != "" {
|
||||
return v, false
|
||||
}
|
||||
if r.URL != nil {
|
||||
if v := Normalize(r.URL.Query().Get("hwid")); v != "" {
|
||||
return v, false
|
||||
}
|
||||
}
|
||||
return Synthetic(r.UserAgent(), ClientIP(r)), true
|
||||
}
|
||||
|
||||
// Synthetic derives a stable pseudo-HWID from a User-Agent and an IP.
|
||||
func Synthetic(ua, ip string) string {
|
||||
sum := sha1.Sum([]byte(ua + "|" + ip))
|
||||
return hex.EncodeToString(sum[:])[:Length]
|
||||
}
|
||||
|
||||
// ClientIP returns the address the request appears to come from, preferring
|
||||
// the reverse-proxy headers: behind a proxy every request would otherwise
|
||||
// carry the proxy's address and every client would collapse into one
|
||||
// synthetic HWID.
|
||||
func ClientIP(r *http.Request) string {
|
||||
if r == nil {
|
||||
return ""
|
||||
}
|
||||
if v := r.Header.Get("X-Forwarded-For"); v != "" {
|
||||
if first, _, _ := strings.Cut(v, ","); strings.TrimSpace(first) != "" {
|
||||
return strings.TrimSpace(first)
|
||||
}
|
||||
}
|
||||
if v := strings.TrimSpace(r.Header.Get("X-Real-IP")); v != "" {
|
||||
return v
|
||||
}
|
||||
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
|
||||
return host
|
||||
}
|
||||
return r.RemoteAddr
|
||||
}
|
||||
|
||||
// Reasons reported by Gate.
|
||||
const (
|
||||
ReasonOK = "ok"
|
||||
ReasonWhitelisted = "whitelisted"
|
||||
ReasonBanned = "banned"
|
||||
ReasonNotWhitelisted = "not whitelisted"
|
||||
ReasonNoHWID = "no hwid"
|
||||
ReasonStoreError = "store error"
|
||||
)
|
||||
|
||||
// Gate decides whether a client may fetch a link.
|
||||
//
|
||||
// whitelist non-empty -> only whitelisted HWIDs pass
|
||||
// whitelist empty -> everyone passes except banned HWIDs
|
||||
//
|
||||
// Banned always loses, including against the whitelist. The stored row wins
|
||||
// over whatever the caller passed in, so an admin ban applies on the very next
|
||||
// request even if the caller reused a stale Client value.
|
||||
//
|
||||
// Errors fail closed: if the database cannot be consulted, nobody gets in.
|
||||
func Gate(st *store.Store, c *model.Client) model.AccessDecision {
|
||||
dec := model.AccessDecision{}
|
||||
if c != nil {
|
||||
dec.HWID = c.HWID
|
||||
dec.Client = c
|
||||
}
|
||||
if st == nil {
|
||||
dec.Reason = ReasonStoreError
|
||||
return dec
|
||||
}
|
||||
id := Normalize(dec.HWID)
|
||||
if id == "" {
|
||||
dec.Reason = ReasonNoHWID
|
||||
return dec
|
||||
}
|
||||
dec.HWID = id
|
||||
|
||||
// Whitelist membership is read from the database only. A Client value
|
||||
// assembled from request data must never be able to claim it, or a device
|
||||
// could whitelist itself by asking nicely. A ban, on the other hand, is
|
||||
// honoured from either side — denying is always the safe direction.
|
||||
banned, allowed := false, false
|
||||
if c != nil {
|
||||
banned = c.Banned
|
||||
}
|
||||
switch stored, err := st.LookupClient(id); {
|
||||
case err == nil && stored != nil:
|
||||
banned = banned || stored.Banned
|
||||
allowed = stored.Allowed
|
||||
dec.Client = stored
|
||||
case errors.Is(err, store.ErrNotFound):
|
||||
// First request from this device; it is not on the whitelist yet.
|
||||
case err != nil:
|
||||
dec.Reason = ReasonStoreError
|
||||
return dec
|
||||
}
|
||||
|
||||
if banned {
|
||||
dec.Reason = ReasonBanned
|
||||
return dec
|
||||
}
|
||||
|
||||
n, err := st.WhitelistCount()
|
||||
if err != nil {
|
||||
dec.Reason = ReasonStoreError
|
||||
return dec
|
||||
}
|
||||
if n > 0 && !allowed {
|
||||
dec.Reason = ReasonNotWhitelisted
|
||||
return dec
|
||||
}
|
||||
|
||||
dec.Allowed = true
|
||||
if n > 0 {
|
||||
dec.Reason = ReasonWhitelisted
|
||||
} else {
|
||||
dec.Reason = ReasonOK
|
||||
}
|
||||
return dec
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
package hwid
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"github.com/omar/prizma/internal/model"
|
||||
"github.com/omar/prizma/internal/store"
|
||||
)
|
||||
|
||||
var hexRe = regexp.MustCompile(`^[0-9a-f]{32}$`)
|
||||
|
||||
func testStore(t *testing.T) *store.Store {
|
||||
t.Helper()
|
||||
st, err := store.Open(filepath.Join(t.TempDir(), "prizma.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("store.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
return st
|
||||
}
|
||||
|
||||
// seed registers a client and applies its administrative flags.
|
||||
func seed(t *testing.T, st *store.Store, id string, banned, allowed bool) *model.Client {
|
||||
t.Helper()
|
||||
c, err := st.TouchClient(model.Client{HWID: id, UserAgent: "ua", IP: "1.2.3.4"})
|
||||
if err != nil {
|
||||
t.Fatalf("TouchClient(%s): %v", id, err)
|
||||
}
|
||||
if banned {
|
||||
if err := st.SetClientBanned(c.ID, true); err != nil {
|
||||
t.Fatalf("SetClientBanned: %v", err)
|
||||
}
|
||||
}
|
||||
if allowed {
|
||||
if err := st.SetClientAllowed(c.ID, true); err != nil {
|
||||
t.Fatalf("SetClientAllowed: %v", err)
|
||||
}
|
||||
}
|
||||
got, err := st.LookupClient(id)
|
||||
if err != nil {
|
||||
t.Fatalf("LookupClient: %v", err)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
for i := 0; i < 1000; i++ {
|
||||
id := New()
|
||||
if !hexRe.MatchString(id) {
|
||||
t.Fatalf("New() = %q, want 32 lowercase hex chars", id)
|
||||
}
|
||||
if seen[id] {
|
||||
t.Fatalf("New() repeated %q", id)
|
||||
}
|
||||
seen[id] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalize(t *testing.T) {
|
||||
tests := []struct{ in, want string }{
|
||||
{" ABCdef ", "abcdef"},
|
||||
{"", ""},
|
||||
{" ", ""},
|
||||
{"AB\x00CD\n", "abcd"},
|
||||
{"0123456789ABCDEF0123456789ABCDEF", "0123456789abcdef0123456789abcdef"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := Normalize(tc.in); got != tc.want {
|
||||
t.Errorf("Normalize(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
long := make([]byte, 300)
|
||||
for i := range long {
|
||||
long[i] = 'a'
|
||||
}
|
||||
if got := Normalize(string(long)); len(got) != 128 {
|
||||
t.Errorf("Normalize did not cap the length: %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsHex(t *testing.T) {
|
||||
if !IsHex(New()) {
|
||||
t.Fatal("New() output must satisfy IsHex")
|
||||
}
|
||||
for _, bad := range []string{"", "zz", "0123456789ABCDEF0123456789ABCDEF", "0123456789abcdef0123456789abcde"} {
|
||||
if IsHex(bad) {
|
||||
t.Errorf("IsHex(%q) = true", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromRequest(t *testing.T) {
|
||||
t.Run("header wins", func(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/sub/tok?hwid=fromquery", nil)
|
||||
r.Header.Set("x-hwid", " DEADBEEFDEADBEEFDEADBEEFDEADBEEF ")
|
||||
id, synthetic := FromRequest(r)
|
||||
if id != "deadbeefdeadbeefdeadbeefdeadbeef" || synthetic {
|
||||
t.Fatalf("got %q synthetic=%v", id, synthetic)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("query fallback", func(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/sub/tok?hwid=ABC123", nil)
|
||||
id, synthetic := FromRequest(r)
|
||||
if id != "abc123" || synthetic {
|
||||
t.Fatalf("got %q synthetic=%v", id, synthetic)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("synthetic from ua and ip", func(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/sub/tok", nil)
|
||||
r.Header.Set("User-Agent", "Happ/1.0")
|
||||
r.RemoteAddr = "203.0.113.9:44321"
|
||||
id, synthetic := FromRequest(r)
|
||||
if !synthetic {
|
||||
t.Fatal("want synthetic=true")
|
||||
}
|
||||
if !hexRe.MatchString(id) {
|
||||
t.Fatalf("synthetic id %q is not 32 lowercase hex chars", id)
|
||||
}
|
||||
if id != Synthetic("Happ/1.0", "203.0.113.9") {
|
||||
t.Fatalf("synthetic id is not sha1(ua|ip)[:32]: %q", id)
|
||||
}
|
||||
|
||||
// Same device, same id; a different UA or IP gives a different id.
|
||||
again, _ := FromRequest(r)
|
||||
if again != id {
|
||||
t.Fatal("synthetic id is not stable")
|
||||
}
|
||||
r.Header.Set("User-Agent", "Clash/2")
|
||||
other, _ := FromRequest(r)
|
||||
if other == id {
|
||||
t.Fatal("different UAs collapsed into one synthetic id")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("forwarded ip is preferred", func(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/sub/tok", nil)
|
||||
r.RemoteAddr = "10.0.0.1:1234"
|
||||
r.Header.Set("X-Forwarded-For", "198.51.100.7, 10.0.0.1")
|
||||
if got := ClientIP(r); got != "198.51.100.7" {
|
||||
t.Fatalf("ClientIP = %q", got)
|
||||
}
|
||||
r.Header.Del("X-Forwarded-For")
|
||||
r.Header.Set("X-Real-IP", "198.51.100.8")
|
||||
if got := ClientIP(r); got != "198.51.100.8" {
|
||||
t.Fatalf("ClientIP = %q", got)
|
||||
}
|
||||
r.Header.Del("X-Real-IP")
|
||||
if got := ClientIP(r); got != "10.0.0.1" {
|
||||
t.Fatalf("ClientIP = %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil request", func(t *testing.T) {
|
||||
id, synthetic := FromRequest(nil)
|
||||
if id != "" || !synthetic {
|
||||
t.Fatalf("got %q %v", id, synthetic)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestGateEmptyWhitelist: with nobody whitelisted the link is open to
|
||||
// everybody except banned devices — the normal Prizma mode.
|
||||
func TestGateEmptyWhitelist(t *testing.T) {
|
||||
st := testStore(t)
|
||||
plain := seed(t, st, "1111111111111111111111111111111a", false, false)
|
||||
banned := seed(t, st, "2222222222222222222222222222222b", true, false)
|
||||
|
||||
if d := Gate(st, plain); !d.Allowed || d.Reason != ReasonOK {
|
||||
t.Fatalf("plain client: %+v", d)
|
||||
}
|
||||
if d := Gate(st, banned); d.Allowed || d.Reason != ReasonBanned {
|
||||
t.Fatalf("banned client: %+v", d)
|
||||
}
|
||||
|
||||
// A device nobody has seen yet is also fine while the whitelist is empty.
|
||||
fresh := &model.Client{HWID: New()}
|
||||
if d := Gate(st, fresh); !d.Allowed {
|
||||
t.Fatalf("unknown client: %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGateWhitelistTransition is the security-critical case: the moment the
|
||||
// first device is whitelisted, every other device must lose access.
|
||||
func TestGateWhitelistTransition(t *testing.T) {
|
||||
st := testStore(t)
|
||||
plain := seed(t, st, "1111111111111111111111111111111a", false, false)
|
||||
banned := seed(t, st, "2222222222222222222222222222222b", true, false)
|
||||
vip := seed(t, st, "3333333333333333333333333333333c", false, false)
|
||||
|
||||
if d := Gate(st, plain); !d.Allowed {
|
||||
t.Fatalf("before the transition the plain client must pass: %+v", d)
|
||||
}
|
||||
|
||||
// Flip the whitelist on.
|
||||
if err := st.SetClientAllowed(vip.ID, true); err != nil {
|
||||
t.Fatalf("SetClientAllowed: %v", err)
|
||||
}
|
||||
|
||||
if d := Gate(st, vip); !d.Allowed || d.Reason != ReasonWhitelisted {
|
||||
t.Fatalf("whitelisted client: %+v", d)
|
||||
}
|
||||
if d := Gate(st, plain); d.Allowed || d.Reason != ReasonNotWhitelisted {
|
||||
t.Fatalf("non-whitelisted client still passes: %+v", d)
|
||||
}
|
||||
if d := Gate(st, banned); d.Allowed || d.Reason != ReasonBanned {
|
||||
t.Fatalf("banned client: %+v", d)
|
||||
}
|
||||
if d := Gate(st, &model.Client{HWID: New()}); d.Allowed {
|
||||
t.Fatalf("unknown client passed a live whitelist: %+v", d)
|
||||
}
|
||||
|
||||
// Ban beats the whitelist.
|
||||
if err := st.SetClientAllowed(banned.ID, true); err != nil {
|
||||
t.Fatalf("SetClientAllowed: %v", err)
|
||||
}
|
||||
if d := Gate(st, banned); d.Allowed || d.Reason != ReasonBanned {
|
||||
t.Fatalf("banned+whitelisted client passed: %+v", d)
|
||||
}
|
||||
|
||||
// Flip the whitelist back off: everybody but the banned device returns.
|
||||
if err := st.SetClientAllowed(vip.ID, false); err != nil {
|
||||
t.Fatalf("SetClientAllowed: %v", err)
|
||||
}
|
||||
if err := st.SetClientAllowed(banned.ID, false); err != nil {
|
||||
t.Fatalf("SetClientAllowed: %v", err)
|
||||
}
|
||||
if n, _ := st.WhitelistCount(); n != 0 {
|
||||
t.Fatalf("whitelist not empty: %d", n)
|
||||
}
|
||||
if d := Gate(st, plain); !d.Allowed || d.Reason != ReasonOK {
|
||||
t.Fatalf("plain client after the whitelist was emptied: %+v", d)
|
||||
}
|
||||
if d := Gate(st, banned); d.Allowed {
|
||||
t.Fatalf("ban did not survive the whitelist being emptied: %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
// The stored row is authoritative: a caller holding a stale Client cannot talk
|
||||
// its way past a ban, and cannot fake whitelist membership either.
|
||||
func TestGateIgnoresStaleCallerState(t *testing.T) {
|
||||
st := testStore(t)
|
||||
c := seed(t, st, "1111111111111111111111111111111a", true, false)
|
||||
|
||||
stale := *c
|
||||
stale.Banned = false
|
||||
stale.Allowed = true
|
||||
if d := Gate(st, &stale); d.Allowed {
|
||||
t.Fatalf("stale unbanned copy passed: %+v", d)
|
||||
}
|
||||
if d := Gate(st, &stale); d.Client == nil || !d.Client.Banned {
|
||||
t.Fatalf("decision did not carry the stored client: %+v", d)
|
||||
}
|
||||
|
||||
vip := seed(t, st, "3333333333333333333333333333333c", false, true)
|
||||
forged := &model.Client{HWID: "4444444444444444444444444444444d", Allowed: true}
|
||||
if d := Gate(st, forged); d.Allowed {
|
||||
t.Fatalf("unknown client claiming Allowed passed: %+v", d)
|
||||
}
|
||||
if d := Gate(st, vip); !d.Allowed {
|
||||
t.Fatalf("real whitelisted client: %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGateEdgeCases(t *testing.T) {
|
||||
st := testStore(t)
|
||||
|
||||
if d := Gate(st, nil); d.Allowed || d.Reason != ReasonNoHWID {
|
||||
t.Fatalf("nil client: %+v", d)
|
||||
}
|
||||
if d := Gate(st, &model.Client{HWID: " "}); d.Allowed || d.Reason != ReasonNoHWID {
|
||||
t.Fatalf("blank hwid: %+v", d)
|
||||
}
|
||||
if d := Gate(nil, &model.Client{HWID: New()}); d.Allowed || d.Reason != ReasonStoreError {
|
||||
t.Fatalf("nil store must fail closed: %+v", d)
|
||||
}
|
||||
|
||||
// Case folding: a ban recorded in lowercase must catch the upper-case form.
|
||||
id := "aabbccddeeff00112233445566778899"
|
||||
seed(t, st, id, true, false)
|
||||
if d := Gate(st, &model.Client{HWID: "AABBCCDDEEFF00112233445566778899"}); d.Allowed {
|
||||
t.Fatalf("case change slipped past a ban: %+v", d)
|
||||
}
|
||||
if d := Gate(st, &model.Client{HWID: "AABBCCDDEEFF00112233445566778899"}); d.HWID != id {
|
||||
t.Fatalf("decision HWID not normalized: %q", d.HWID)
|
||||
}
|
||||
|
||||
// A closed store must not hand out access.
|
||||
st2, err := store.Open(filepath.Join(t.TempDir(), "closed.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
st2.Close()
|
||||
if d := Gate(st2, &model.Client{HWID: New()}); d.Allowed || d.Reason != ReasonStoreError {
|
||||
t.Fatalf("closed store must fail closed: %+v", d)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
// Package merge filters node lists and merges the members of a group into the
|
||||
// single list a group link renders.
|
||||
//
|
||||
// It is deliberately free of any format knowledge: it works on model.Node only,
|
||||
// so the same filtering rules apply no matter which subscription format the
|
||||
// nodes were parsed from. Content matching reaches into the node's raw payload
|
||||
// (the URI or the raw map it was born from) so an operator can filter on server
|
||||
// addresses, transport parameters or reality keys — not only on display names.
|
||||
package merge
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/omar/prizma/internal/model"
|
||||
)
|
||||
|
||||
// Apply runs a filter over nodes and returns the survivors in their original
|
||||
// order. Evaluation order is fixed by the contract: IncludeName, ExcludeName,
|
||||
// IncludeContent, ExcludeContent, Protocols, Limit. An empty include pattern
|
||||
// passes everything; a malformed pattern is a hard error, because silently
|
||||
// ignoring a broken regex would hand the user a subscription that quietly
|
||||
// contains the wrong servers.
|
||||
func Apply(nodes []model.Node, f model.Filter) ([]model.Node, error) {
|
||||
c, err := compileFilter(f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.apply(nodes), nil
|
||||
}
|
||||
|
||||
// Group assembles the final node list of g. members[i] holds the nodes fetched
|
||||
// for g.Members[i]; a member that could not be fetched contributes an empty
|
||||
// (or nil) slice, which is not an error — the caller decides when "every member
|
||||
// failed" becomes fatal.
|
||||
//
|
||||
// Pipeline: per-member filter, per-member prefix, group filter, optional dedupe
|
||||
// by protocol|server|port, then NameTemplate expansion. Input nodes are never
|
||||
// mutated: every node that gets renamed is cloned first.
|
||||
func Group(members [][]model.Node, g model.Group) ([]model.Node, error) {
|
||||
groupFilter, err := compileFilter(g.Filter)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("group %q: %w", g.Name, err)
|
||||
}
|
||||
|
||||
var merged []model.Node
|
||||
for i, nodes := range members {
|
||||
var m model.GroupMember
|
||||
if i < len(g.Members) {
|
||||
m = g.Members[i]
|
||||
}
|
||||
mf, err := compileFilter(m.Filter)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("group %q member %d: %w", g.Name, i, err)
|
||||
}
|
||||
kept := mf.apply(nodes)
|
||||
for _, n := range kept {
|
||||
c := n.Clone()
|
||||
// Provenance is the assembler's job, not the parser's.
|
||||
if m.SourceID != 0 {
|
||||
c.SourceID = m.SourceID
|
||||
}
|
||||
if m.Name != "" {
|
||||
c.SourceName = m.Name
|
||||
}
|
||||
if m.Prefix != "" {
|
||||
c.Name = m.Prefix + c.Name
|
||||
}
|
||||
merged = append(merged, c)
|
||||
}
|
||||
}
|
||||
|
||||
merged = groupFilter.apply(merged)
|
||||
|
||||
if g.Dedupe {
|
||||
seen := make(map[string]bool, len(merged))
|
||||
out := merged[:0:0]
|
||||
for _, n := range merged {
|
||||
k := dedupeKey(n)
|
||||
if seen[k] {
|
||||
continue
|
||||
}
|
||||
seen[k] = true
|
||||
out = append(out, n)
|
||||
}
|
||||
merged = out
|
||||
}
|
||||
|
||||
if g.NameTemplate != "" {
|
||||
for i := range merged {
|
||||
merged[i].Name = expandName(g.NameTemplate, merged[i], i+1)
|
||||
}
|
||||
}
|
||||
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
// dedupeKey identifies "the same server reached the same way". Names and
|
||||
// transport details are deliberately excluded: two entries that differ only by
|
||||
// label are the duplicate this option exists to remove.
|
||||
func dedupeKey(n model.Node) string {
|
||||
return strings.ToLower(normalizeProto(n.Protocol)) + "|" +
|
||||
strings.ToLower(n.Server) + "|" + strconv.Itoa(n.Port)
|
||||
}
|
||||
|
||||
// expandName renders a NameTemplate for one node. index is 1-based.
|
||||
func expandName(tpl string, n model.Node, index int) string {
|
||||
out := strings.NewReplacer(
|
||||
"{name}", n.Name,
|
||||
"{source}", n.SourceName,
|
||||
"{index}", strconv.Itoa(index),
|
||||
"{protocol}", n.Protocol,
|
||||
"{server}", n.Server,
|
||||
).Replace(tpl)
|
||||
out = strings.TrimSpace(out)
|
||||
if out == "" {
|
||||
return n.Name
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// compiled is a model.Filter with its regexps compiled once per call instead of
|
||||
// once per node.
|
||||
type compiled struct {
|
||||
includeName *regexp.Regexp
|
||||
excludeName *regexp.Regexp
|
||||
includeContent *regexp.Regexp
|
||||
excludeContent *regexp.Regexp
|
||||
protocols map[string]bool
|
||||
limit int
|
||||
zero bool
|
||||
}
|
||||
|
||||
func compileFilter(f model.Filter) (*compiled, error) {
|
||||
c := &compiled{limit: f.Limit, zero: f.IsZero()}
|
||||
if c.zero {
|
||||
return c, nil
|
||||
}
|
||||
var err error
|
||||
if c.includeName, err = compileRe("include_name", f.IncludeName, f.CaseSensitive); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c.excludeName, err = compileRe("exclude_name", f.ExcludeName, f.CaseSensitive); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c.includeContent, err = compileRe("include_content", f.IncludeContent, f.CaseSensitive); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c.excludeContent, err = compileRe("exclude_content", f.ExcludeContent, f.CaseSensitive); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(f.Protocols) > 0 {
|
||||
c.protocols = make(map[string]bool, len(f.Protocols))
|
||||
for _, p := range f.Protocols {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
c.protocols[normalizeProto(p)] = true
|
||||
}
|
||||
if len(c.protocols) == 0 {
|
||||
c.protocols = nil
|
||||
}
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func compileRe(field, pattern string, caseSensitive bool) (*regexp.Regexp, error) {
|
||||
if pattern == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if !caseSensitive {
|
||||
pattern = "(?i)" + pattern
|
||||
}
|
||||
re, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("filter %s: %w", field, err)
|
||||
}
|
||||
return re, nil
|
||||
}
|
||||
|
||||
func (c *compiled) apply(nodes []model.Node) []model.Node {
|
||||
if c == nil || (c.zero && c.limit == 0) {
|
||||
return nodes
|
||||
}
|
||||
out := make([]model.Node, 0, len(nodes))
|
||||
for _, n := range nodes {
|
||||
if c.includeName != nil && !c.includeName.MatchString(n.Name) {
|
||||
continue
|
||||
}
|
||||
if c.excludeName != nil && c.excludeName.MatchString(n.Name) {
|
||||
continue
|
||||
}
|
||||
if c.includeContent != nil || c.excludeContent != nil {
|
||||
body := rawContent(n)
|
||||
if c.includeContent != nil && !c.includeContent.MatchString(body) {
|
||||
continue
|
||||
}
|
||||
if c.excludeContent != nil && c.excludeContent.MatchString(body) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if c.protocols != nil && !c.protocols[normalizeProto(n.Protocol)] {
|
||||
continue
|
||||
}
|
||||
out = append(out, n)
|
||||
if c.limit > 0 && len(out) >= c.limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// rawContent is what content filters match against: the node exactly as the
|
||||
// upstream shipped it when we have that, so `include_content: "reality"` or
|
||||
// `exclude_content: "\\.ru:"` work on real payload text.
|
||||
func rawContent(n model.Node) string {
|
||||
if n.RawURI != "" {
|
||||
return n.RawURI
|
||||
}
|
||||
for _, m := range []map[string]any{n.RawClash, n.RawSingBox, n.RawXray} {
|
||||
if len(m) == 0 {
|
||||
continue
|
||||
}
|
||||
if s, ok := jsonText(m); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
// No raw payload (a synthesised node): the normalized fields still let the
|
||||
// user filter on server, port and transport.
|
||||
if s, ok := jsonText(n); ok {
|
||||
return s
|
||||
}
|
||||
return n.Name + " " + n.Server
|
||||
}
|
||||
|
||||
// jsonText serializes without HTML escaping so patterns containing &, < or >
|
||||
// still match the text the user actually sees.
|
||||
func jsonText(v any) (string, bool) {
|
||||
var buf bytes.Buffer
|
||||
enc := json.NewEncoder(&buf)
|
||||
enc.SetEscapeHTML(false)
|
||||
if err := enc.Encode(v); err != nil {
|
||||
return "", false
|
||||
}
|
||||
return strings.TrimRight(buf.String(), "\n"), true
|
||||
}
|
||||
|
||||
// normalizeProto folds the aliases users type (and the spellings different
|
||||
// formats use) onto the model.Proto* vocabulary.
|
||||
func normalizeProto(p string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(p)) {
|
||||
case "ss", "shadowsocks":
|
||||
return model.ProtoShadowsock
|
||||
case "ssr", "shadowsocksr":
|
||||
return model.ProtoSSR
|
||||
case "hy", "hysteria", "hysteria1":
|
||||
return model.ProtoHysteria
|
||||
case "hy2", "hysteria2":
|
||||
return model.ProtoHysteria2
|
||||
case "socks", "socks5", "socks4", "socks4a", "socks5h":
|
||||
return model.ProtoSOCKS
|
||||
case "http", "https", "http-proxy":
|
||||
return model.ProtoHTTP
|
||||
case "wg", "wireguard":
|
||||
return model.ProtoWireGuard
|
||||
default:
|
||||
return strings.ToLower(strings.TrimSpace(p))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
package merge
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/omar/prizma/internal/model"
|
||||
"github.com/omar/prizma/internal/subfmt"
|
||||
)
|
||||
|
||||
// tstNodes is the synthetic fixture: three protocols, three regions, one
|
||||
// duplicate endpoint, so every filter branch has something to bite on.
|
||||
func tstNodes() []model.Node {
|
||||
return []model.Node{
|
||||
{Name: "🇩🇪 Germany 1", Protocol: model.ProtoVLESS, Server: "de1.example.com", Port: 443,
|
||||
UUID: "u-1", Security: "reality", RawURI: "vless://u-1@de1.example.com:443?security=reality#Germany"},
|
||||
{Name: "🇩🇪 Germany 2", Protocol: model.ProtoVLESS, Server: "de2.example.com", Port: 443,
|
||||
UUID: "u-2", RawURI: "vless://u-2@de2.example.com:443?type=ws#Germany2"},
|
||||
{Name: "🇳🇱 Netherlands", Protocol: model.ProtoTrojan, Server: "nl1.example.com", Port: 443,
|
||||
Password: "pw", RawURI: "trojan://pw@nl1.example.com:443#NL"},
|
||||
{Name: "🇷🇺 Russia [expired]", Protocol: model.ProtoShadowsock, Server: "ru1.example.com", Port: 8388,
|
||||
Method: "chacha20-ietf-poly1305", Password: "pw", RawURI: "ss://x@ru1.example.com:8388#RU"},
|
||||
{Name: "🇩🇪 Germany 1 (copy)", Protocol: model.ProtoVLESS, Server: "de1.example.com", Port: 443,
|
||||
UUID: "u-1", RawURI: "vless://u-1@de1.example.com:443#copy"},
|
||||
}
|
||||
}
|
||||
|
||||
func tstNames(nodes []model.Node) []string {
|
||||
out := make([]string, len(nodes))
|
||||
for i, n := range nodes {
|
||||
out[i] = n.Name
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func tstApply(t *testing.T, f model.Filter) []string {
|
||||
t.Helper()
|
||||
got, err := Apply(tstNodes(), f)
|
||||
if err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
return tstNames(got)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Apply
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestApplyFilters(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
filter model.Filter
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "empty filter passes everything",
|
||||
filter: model.Filter{},
|
||||
want: tstNames(tstNodes()),
|
||||
},
|
||||
{
|
||||
name: "include by name",
|
||||
filter: model.Filter{IncludeName: "germany"},
|
||||
want: []string{"🇩🇪 Germany 1", "🇩🇪 Germany 2", "🇩🇪 Germany 1 (copy)"},
|
||||
},
|
||||
{
|
||||
name: "include is case sensitive on request",
|
||||
filter: model.Filter{IncludeName: "germany", CaseSensitive: true},
|
||||
want: []string{},
|
||||
},
|
||||
{
|
||||
name: "exclude runs after include",
|
||||
filter: model.Filter{IncludeName: "Germany", ExcludeName: `\(copy\)`},
|
||||
want: []string{"🇩🇪 Germany 1", "🇩🇪 Germany 2"},
|
||||
},
|
||||
{
|
||||
name: "exclude by bracketed marker",
|
||||
filter: model.Filter{ExcludeName: `\[expired\]`},
|
||||
want: []string{"🇩🇪 Germany 1", "🇩🇪 Germany 2", "🇳🇱 Netherlands", "🇩🇪 Germany 1 (copy)"},
|
||||
},
|
||||
{
|
||||
name: "content matches the raw payload, not the name",
|
||||
filter: model.Filter{IncludeContent: "security=reality"},
|
||||
want: []string{"🇩🇪 Germany 1"},
|
||||
},
|
||||
{
|
||||
name: "content matches a server address",
|
||||
filter: model.Filter{IncludeContent: `@de\d\.example\.com`},
|
||||
want: []string{"🇩🇪 Germany 1", "🇩🇪 Germany 2", "🇩🇪 Germany 1 (copy)"},
|
||||
},
|
||||
{
|
||||
name: "content exclude drops a transport",
|
||||
filter: model.Filter{ExcludeContent: "type=ws"},
|
||||
want: []string{"🇩🇪 Germany 1", "🇳🇱 Netherlands", "🇷🇺 Russia [expired]", "🇩🇪 Germany 1 (copy)"},
|
||||
},
|
||||
{
|
||||
name: "protocol allow-list",
|
||||
filter: model.Filter{Protocols: []string{model.ProtoTrojan, model.ProtoShadowsock}},
|
||||
want: []string{"🇳🇱 Netherlands", "🇷🇺 Russia [expired]"},
|
||||
},
|
||||
{
|
||||
name: "protocol aliases are honoured",
|
||||
filter: model.Filter{Protocols: []string{"shadowsocks"}},
|
||||
want: []string{"🇷🇺 Russia [expired]"},
|
||||
},
|
||||
{
|
||||
name: "limit truncates after every other rule",
|
||||
filter: model.Filter{IncludeName: "Germany", Limit: 2},
|
||||
want: []string{"🇩🇪 Germany 1", "🇩🇪 Germany 2"},
|
||||
},
|
||||
{
|
||||
name: "limit alone still applies",
|
||||
filter: model.Filter{Limit: 1},
|
||||
want: []string{"🇩🇪 Germany 1"},
|
||||
},
|
||||
{
|
||||
name: "everything combined",
|
||||
filter: model.Filter{IncludeName: "🇩🇪|🇳🇱", ExcludeName: "copy", IncludeContent: "example.com", Protocols: []string{"vless"}},
|
||||
want: []string{"🇩🇪 Germany 1", "🇩🇪 Germany 2"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := tstApply(t, tc.filter)
|
||||
if len(got) == 0 && len(tc.want) == 0 {
|
||||
return
|
||||
}
|
||||
if !reflect.DeepEqual(got, tc.want) {
|
||||
t.Errorf("got %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyInvalidRegexIsAnError(t *testing.T) {
|
||||
bad := "([unclosed"
|
||||
for _, f := range []model.Filter{
|
||||
{IncludeName: bad},
|
||||
{ExcludeName: bad},
|
||||
{IncludeContent: bad},
|
||||
{ExcludeContent: bad},
|
||||
} {
|
||||
if _, err := Apply(tstNodes(), f); err == nil {
|
||||
t.Errorf("Apply(%+v) accepted a broken pattern", f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyContentUsesJSONWhenThereIsNoURI covers the nodes that came from a
|
||||
// document format: content filtering must still reach the server address.
|
||||
func TestApplyContentUsesJSONWhenThereIsNoURI(t *testing.T) {
|
||||
nodes := []model.Node{
|
||||
{Name: "clash node", Protocol: model.ProtoVLESS, Server: "x.example.com", Port: 443,
|
||||
RawClash: map[string]any{"name": "clash node", "servername": "sni.example.com", "client-fingerprint": "firefox"}},
|
||||
{Name: "bare node", Protocol: model.ProtoTrojan, Server: "y.example.net", Port: 8443},
|
||||
}
|
||||
got, err := Apply(nodes, model.Filter{IncludeContent: "firefox"})
|
||||
if err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].Name != "clash node" {
|
||||
t.Fatalf("raw clash payload was not searched: %q", tstNames(got))
|
||||
}
|
||||
|
||||
got, err = Apply(nodes, model.Filter{IncludeContent: `example\.net`})
|
||||
if err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].Name != "bare node" {
|
||||
t.Fatalf("normalized fields were not searched: %q", tstNames(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyDoesNotMutateInput(t *testing.T) {
|
||||
in := tstNodes()
|
||||
before := tstNames(in)
|
||||
if _, err := Apply(in, model.Filter{IncludeName: "Germany", Limit: 1}); err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(tstNames(in), before) {
|
||||
t.Error("Apply mutated its input")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func tstGroup() model.Group {
|
||||
return model.Group{
|
||||
Name: "Bundle",
|
||||
Members: []model.GroupMember{
|
||||
{SourceID: 1, Name: "Alpha", Prefix: "[A] "},
|
||||
{SourceID: 2, Name: "Beta", Prefix: "[B] "},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupAppliesMemberFilterPrefixThenGroupFilter(t *testing.T) {
|
||||
g := tstGroup()
|
||||
g.Members[0].Filter = model.Filter{IncludeName: "Germany", ExcludeName: "copy"}
|
||||
g.Members[1].Filter = model.Filter{Protocols: []string{"trojan", "ss"}}
|
||||
g.Filter = model.Filter{ExcludeName: `\[expired\]`}
|
||||
|
||||
out, err := Group([][]model.Node{tstNodes(), tstNodes()}, g)
|
||||
if err != nil {
|
||||
t.Fatalf("Group: %v", err)
|
||||
}
|
||||
want := []string{"[A] 🇩🇪 Germany 1", "[A] 🇩🇪 Germany 2", "[B] 🇳🇱 Netherlands"}
|
||||
if got := tstNames(out); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %q, want %q", got, want)
|
||||
}
|
||||
for _, n := range out {
|
||||
switch {
|
||||
case strings.HasPrefix(n.Name, "[A] "):
|
||||
if n.SourceID != 1 || n.SourceName != "Alpha" {
|
||||
t.Errorf("%s: provenance = %d/%q", n.Name, n.SourceID, n.SourceName)
|
||||
}
|
||||
case strings.HasPrefix(n.Name, "[B] "):
|
||||
if n.SourceID != 2 || n.SourceName != "Beta" {
|
||||
t.Errorf("%s: provenance = %d/%q", n.Name, n.SourceID, n.SourceName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupOrderIsStable(t *testing.T) {
|
||||
g := tstGroup()
|
||||
first, err := Group([][]model.Node{tstNodes(), tstNodes()}, g)
|
||||
if err != nil {
|
||||
t.Fatalf("Group: %v", err)
|
||||
}
|
||||
for i := 0; i < 5; i++ {
|
||||
again, err := Group([][]model.Node{tstNodes(), tstNodes()}, g)
|
||||
if err != nil {
|
||||
t.Fatalf("Group: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(tstNames(again), tstNames(first)) {
|
||||
t.Fatal("group order is not stable across calls")
|
||||
}
|
||||
}
|
||||
// Member order is render order: everything from Alpha precedes Beta.
|
||||
names := tstNames(first)
|
||||
if !strings.HasPrefix(names[0], "[A] ") || !strings.HasPrefix(names[len(names)-1], "[B] ") {
|
||||
t.Errorf("members were not rendered in order: %q", names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupDedupeByProtocolServerPort(t *testing.T) {
|
||||
g := tstGroup()
|
||||
g.Dedupe = true
|
||||
|
||||
out, err := Group([][]model.Node{tstNodes(), tstNodes()}, g)
|
||||
if err != nil {
|
||||
t.Fatalf("Group: %v", err)
|
||||
}
|
||||
// Two identical members plus the built-in duplicate endpoint collapse to
|
||||
// the four distinct protocol|server|port triples, all from the first member.
|
||||
want := []string{"[A] 🇩🇪 Germany 1", "[A] 🇩🇪 Germany 2", "[A] 🇳🇱 Netherlands", "[A] 🇷🇺 Russia [expired]"}
|
||||
if got := tstNames(out); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %q, want %q", got, want)
|
||||
}
|
||||
|
||||
g.Dedupe = false
|
||||
out, err = Group([][]model.Node{tstNodes(), tstNodes()}, g)
|
||||
if err != nil {
|
||||
t.Fatalf("Group: %v", err)
|
||||
}
|
||||
if len(out) != 10 {
|
||||
t.Fatalf("without dedupe got %d nodes, want 10", len(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupNameTemplate(t *testing.T) {
|
||||
g := model.Group{
|
||||
Name: "Bundle",
|
||||
Members: []model.GroupMember{{SourceID: 7, Name: "Alpha"}},
|
||||
Filter: model.Filter{Protocols: []string{"vless"}, Limit: 2},
|
||||
NameTemplate: "{index}. {source} | {name} ({protocol}@{server})",
|
||||
}
|
||||
out, err := Group([][]model.Node{tstNodes()}, g)
|
||||
if err != nil {
|
||||
t.Fatalf("Group: %v", err)
|
||||
}
|
||||
want := []string{
|
||||
"1. Alpha | 🇩🇪 Germany 1 (vless@de1.example.com)",
|
||||
"2. Alpha | 🇩🇪 Germany 2 (vless@de2.example.com)",
|
||||
}
|
||||
if got := tstNames(out); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupNameTemplateFallsBackWhenItRendersEmpty(t *testing.T) {
|
||||
g := model.Group{
|
||||
Members: []model.GroupMember{{SourceID: 1}},
|
||||
NameTemplate: "{source}", // no source name configured
|
||||
Filter: model.Filter{Limit: 1},
|
||||
}
|
||||
out, err := Group([][]model.Node{tstNodes()}, g)
|
||||
if err != nil {
|
||||
t.Fatalf("Group: %v", err)
|
||||
}
|
||||
if out[0].Name != "🇩🇪 Germany 1" {
|
||||
t.Errorf("name = %q, want the original", out[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupNeverMutatesTheInputNodes(t *testing.T) {
|
||||
members := [][]model.Node{tstNodes(), tstNodes()}
|
||||
before := append(tstNames(members[0]), tstNames(members[1])...)
|
||||
|
||||
g := tstGroup()
|
||||
g.NameTemplate = "{index} {name}"
|
||||
if _, err := Group(members, g); err != nil {
|
||||
t.Fatalf("Group: %v", err)
|
||||
}
|
||||
after := append(tstNames(members[0]), tstNames(members[1])...)
|
||||
if !reflect.DeepEqual(before, after) {
|
||||
t.Error("Group mutated the caller's nodes; the fetch cache would be poisoned")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupPropagatesBrokenPatterns(t *testing.T) {
|
||||
g := tstGroup()
|
||||
g.Members[1].Filter = model.Filter{IncludeName: "*bad"}
|
||||
if _, err := Group([][]model.Node{tstNodes(), tstNodes()}, g); err == nil {
|
||||
t.Error("a broken member pattern was accepted")
|
||||
}
|
||||
|
||||
g = tstGroup()
|
||||
g.Filter = model.Filter{ExcludeContent: "(("}
|
||||
if _, err := Group([][]model.Node{tstNodes(), tstNodes()}, g); err == nil {
|
||||
t.Error("a broken group pattern was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupToleratesMemberCountMismatch(t *testing.T) {
|
||||
// A source that failed to fetch contributes nothing; a member list shorter
|
||||
// than the fetched slices must not panic either.
|
||||
g := tstGroup()
|
||||
out, err := Group([][]model.Node{tstNodes(), nil}, g)
|
||||
if err != nil {
|
||||
t.Fatalf("Group: %v", err)
|
||||
}
|
||||
if len(out) != 5 {
|
||||
t.Fatalf("got %d nodes, want 5", len(out))
|
||||
}
|
||||
|
||||
g.Members = g.Members[:1]
|
||||
out, err = Group([][]model.Node{tstNodes(), tstNodes()}, g)
|
||||
if err != nil {
|
||||
t.Fatalf("Group: %v", err)
|
||||
}
|
||||
if len(out) != 10 {
|
||||
t.Fatalf("got %d nodes, want 10", len(out))
|
||||
}
|
||||
if out[9].SourceID != 0 {
|
||||
t.Error("an unconfigured member picked up provenance")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupOfNothing(t *testing.T) {
|
||||
out, err := Group(nil, model.Group{Name: "empty"})
|
||||
if err != nil {
|
||||
t.Fatalf("Group: %v", err)
|
||||
}
|
||||
if len(out) != 0 {
|
||||
t.Fatalf("got %d nodes, want none", len(out))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// against the real captures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func tstCapture(t *testing.T, name string) []model.Node {
|
||||
t.Helper()
|
||||
body, err := os.ReadFile(filepath.Join("..", "subfmt", "testdata", name))
|
||||
if err != nil {
|
||||
t.Fatalf("read capture: %v", err)
|
||||
}
|
||||
nodes, _, err := subfmt.Parse(body, "")
|
||||
if err != nil {
|
||||
t.Fatalf("parse capture: %v", err)
|
||||
}
|
||||
if len(nodes) != 34 {
|
||||
t.Fatalf("capture parsed to %d nodes, want 34", len(nodes))
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
|
||||
// TestGroupMergesTheSameSubscriptionTwice is the shape of the real feature: two
|
||||
// sources that happen to serve the same servers, merged, deduped and relabelled.
|
||||
func TestGroupMergesTheSameSubscriptionTwice(t *testing.T) {
|
||||
clash := tstCapture(t, "live_clash.yaml")
|
||||
uris := tstCapture(t, "live_b64.txt")
|
||||
|
||||
g := model.Group{
|
||||
Name: "All",
|
||||
Members: []model.GroupMember{
|
||||
{SourceID: 1, Name: "clash-src"},
|
||||
{SourceID: 2, Name: "uri-src"},
|
||||
},
|
||||
}
|
||||
merged, err := Group([][]model.Node{clash, uris}, g)
|
||||
if err != nil {
|
||||
t.Fatalf("Group: %v", err)
|
||||
}
|
||||
if len(merged) != 68 {
|
||||
t.Fatalf("merged %d nodes, want 68", len(merged))
|
||||
}
|
||||
|
||||
g.Dedupe = true
|
||||
deduped, err := Group([][]model.Node{clash, uris}, g)
|
||||
if err != nil {
|
||||
t.Fatalf("Group: %v", err)
|
||||
}
|
||||
single, err := Group([][]model.Node{clash}, model.Group{
|
||||
Members: []model.GroupMember{{SourceID: 1, Name: "clash-src"}},
|
||||
Dedupe: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Group: %v", err)
|
||||
}
|
||||
if len(deduped) != len(single) {
|
||||
t.Errorf("deduping two copies gave %d nodes, one copy gives %d", len(deduped), len(single))
|
||||
}
|
||||
if len(deduped) >= 68 {
|
||||
t.Errorf("dedupe removed nothing (%d nodes)", len(deduped))
|
||||
}
|
||||
for _, n := range deduped {
|
||||
if n.SourceName != "clash-src" {
|
||||
t.Errorf("dedupe kept the later duplicate: %q from %q", n.Name, n.SourceName)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGroupFiltersRealNodesByContent proves the point of content filtering:
|
||||
// selecting reality servers is impossible from names alone.
|
||||
func TestGroupFiltersRealNodesByContent(t *testing.T) {
|
||||
uris := tstCapture(t, "live_b64.txt")
|
||||
|
||||
reality, err := Apply(uris, model.Filter{IncludeContent: "security=reality"})
|
||||
if err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
if len(reality) == 0 || len(reality) == len(uris) {
|
||||
t.Fatalf("reality filter selected %d of %d nodes", len(reality), len(uris))
|
||||
}
|
||||
for _, n := range reality {
|
||||
if n.Security != "reality" {
|
||||
t.Errorf("%q is not a reality node", n.Name)
|
||||
}
|
||||
}
|
||||
|
||||
vless, err := Apply(uris, model.Filter{Protocols: []string{"vless"}})
|
||||
if err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
if len(vless) != 29 {
|
||||
t.Errorf("vless filter kept %d nodes, want 29", len(vless))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// Source is one upstream subscription that Prizma proxies.
|
||||
//
|
||||
// The whole point of Prizma: the upstream panel (Remnawave & friends) pins a
|
||||
// subscription to ONE device via the x-hwid header. Prizma stores that pinned
|
||||
// HWID here and presents it to the upstream on every fetch, while the client
|
||||
// side of the link is unrestricted — so a single upstream seat serves any
|
||||
// number of devices.
|
||||
type Source struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
|
||||
// HWID presented UPSTREAM. Generated on create when left empty.
|
||||
HWID string `json:"hwid"`
|
||||
|
||||
// Device identity sent alongside the HWID. Empty fields are omitted.
|
||||
DeviceOS string `json:"device_os"`
|
||||
VerOS string `json:"ver_os"`
|
||||
DeviceModel string `json:"device_model"`
|
||||
|
||||
// UserAgent sent upstream. Empty means "forward the client's UA", which is
|
||||
// what makes format negotiation work end to end (a Clash client asks
|
||||
// Prizma, Prizma asks the upstream as a Clash client, YAML comes back).
|
||||
// Setting it pins the upstream format regardless of who is asking.
|
||||
UserAgent string `json:"user_agent"`
|
||||
|
||||
// ForwardClientUA, when false together with a non-empty UserAgent, hides
|
||||
// the client UA entirely. When true the client UA is forwarded and
|
||||
// UserAgent only acts as the fallback for clients that send none.
|
||||
ForwardClientUA bool `json:"forward_client_ua"`
|
||||
|
||||
// ExtraHeaders are added to the upstream request verbatim.
|
||||
ExtraHeaders map[string]string `json:"extra_headers,omitempty"`
|
||||
|
||||
// CacheTTL seconds; 0 uses the global default.
|
||||
CacheTTL int `json:"cache_ttl"`
|
||||
|
||||
Enabled bool `json:"enabled"`
|
||||
Token string `json:"token"` // public link slug
|
||||
Note string `json:"note,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
// Runtime stats, filled by the store on read.
|
||||
LastFetchAt *time.Time `json:"last_fetch_at,omitempty"`
|
||||
LastFetchOK bool `json:"last_fetch_ok"`
|
||||
LastFetchError string `json:"last_fetch_error,omitempty"`
|
||||
LastNodeCount int `json:"last_node_count"`
|
||||
Hits int64 `json:"hits"`
|
||||
}
|
||||
|
||||
// Group merges several Sources into a single subscription link.
|
||||
type Group struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Token string `json:"token"`
|
||||
|
||||
// Members in render order.
|
||||
Members []GroupMember `json:"members"`
|
||||
|
||||
// Filters applied to the merged node list.
|
||||
Filter Filter `json:"filter"`
|
||||
|
||||
// OutputFormat pins the rendered format ("" = negotiate from the client UA
|
||||
// / ?format= query, the sane default).
|
||||
OutputFormat string `json:"output_format"`
|
||||
|
||||
// NameTemplate renames nodes on render. Supported placeholders:
|
||||
// {name} {source} {index} {protocol} {server}. Empty keeps the original.
|
||||
NameTemplate string `json:"name_template"`
|
||||
|
||||
// Dedupe drops nodes whose server:port:protocol triple already appeared.
|
||||
Dedupe bool `json:"dedupe"`
|
||||
|
||||
Enabled bool `json:"enabled"`
|
||||
Note string `json:"note,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
Hits int64 `json:"hits"`
|
||||
}
|
||||
|
||||
// GroupMember is one Source inside a Group, with its own optional filter that
|
||||
// runs BEFORE the group-wide one.
|
||||
type GroupMember struct {
|
||||
SourceID int64 `json:"source_id"`
|
||||
Name string `json:"name,omitempty"` // denormalized for the UI
|
||||
Prefix string `json:"prefix,omitempty"`
|
||||
Filter Filter `json:"filter"`
|
||||
}
|
||||
|
||||
// Filter selects nodes by name, by raw content, and by protocol.
|
||||
//
|
||||
// Evaluation order, all optional: IncludeName -> ExcludeName ->
|
||||
// IncludeContent -> ExcludeContent -> Protocols -> Limit. An empty include
|
||||
// pattern means "everything passes"; a non-empty one is a hard gate. Patterns
|
||||
// are Go RE2, case-insensitive unless CaseSensitive is set.
|
||||
type Filter struct {
|
||||
IncludeName string `json:"include_name,omitempty"`
|
||||
ExcludeName string `json:"exclude_name,omitempty"`
|
||||
IncludeContent string `json:"include_content,omitempty"`
|
||||
ExcludeContent string `json:"exclude_content,omitempty"`
|
||||
|
||||
// Protocols is an allow-list of Proto* values; empty allows all.
|
||||
Protocols []string `json:"protocols,omitempty"`
|
||||
|
||||
CaseSensitive bool `json:"case_sensitive,omitempty"`
|
||||
|
||||
// Limit caps the surviving node count; 0 means unlimited.
|
||||
Limit int `json:"limit,omitempty"`
|
||||
}
|
||||
|
||||
// IsZero reports whether the filter would let everything through untouched.
|
||||
func (f Filter) IsZero() bool {
|
||||
return f.IncludeName == "" && f.ExcludeName == "" &&
|
||||
f.IncludeContent == "" && f.ExcludeContent == "" &&
|
||||
len(f.Protocols) == 0 && f.Limit == 0
|
||||
}
|
||||
|
||||
// Client is a device seen on a public subscription link, keyed by the HWID it
|
||||
// presented (or a synthetic fingerprint when it presented none).
|
||||
type Client struct {
|
||||
ID int64 `json:"id"`
|
||||
HWID string `json:"hwid"`
|
||||
|
||||
// Synthetic is true when HWID was derived from UA+IP because the client
|
||||
// sent no x-hwid header.
|
||||
Synthetic bool `json:"synthetic"`
|
||||
|
||||
UserAgent string `json:"user_agent"`
|
||||
IP string `json:"ip"`
|
||||
DeviceOS string `json:"device_os,omitempty"`
|
||||
LastLink string `json:"last_link,omitempty"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Banned bool `json:"banned"`
|
||||
Allowed bool `json:"allowed"` // member of the whitelist
|
||||
Hits int64 `json:"hits"`
|
||||
FirstSeen time.Time `json:"first_seen"`
|
||||
LastSeen time.Time `json:"last_seen"`
|
||||
LastStatus int `json:"last_status"`
|
||||
}
|
||||
|
||||
// AccessDecision is the outcome of the HWID gate.
|
||||
type AccessDecision struct {
|
||||
Allowed bool
|
||||
Reason string
|
||||
HWID string
|
||||
Client *Client
|
||||
}
|
||||
|
||||
// LinkKind distinguishes the two public link types.
|
||||
type LinkKind string
|
||||
|
||||
const (
|
||||
LinkSource LinkKind = "source"
|
||||
LinkGroup LinkKind = "group"
|
||||
)
|
||||
|
||||
// Stats is the dashboard payload.
|
||||
type Stats struct {
|
||||
Sources int `json:"sources"`
|
||||
Groups int `json:"groups"`
|
||||
Clients int `json:"clients"`
|
||||
BannedClients int `json:"banned_clients"`
|
||||
Whitelisted int `json:"whitelisted"`
|
||||
Requests24h int64 `json:"requests_24h"`
|
||||
TotalRequests int64 `json:"total_requests"`
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// Package model holds the shared domain types of Prizma.
|
||||
//
|
||||
// The single most important type here is Node: the format-neutral
|
||||
// representation of one proxy server. Every subscription format Prizma
|
||||
// understands (URI lines, base64 blobs, Clash/Clash.Meta YAML, sing-box JSON,
|
||||
// Xray/Happ JSON) is parsed INTO []Node and rendered back OUT of []Node.
|
||||
//
|
||||
// Fidelity rule: a Node keeps the raw representation it was born from
|
||||
// (RawURI / RawClash / RawSingBox / RawXray). A renderer targeting the same
|
||||
// format family MUST emit the raw payload verbatim instead of re-deriving it,
|
||||
// so a group of same-format sources round-trips bit-for-bit and no
|
||||
// vendor-specific field is ever silently dropped. Cross-format rendering falls
|
||||
// back to the normalized fields below.
|
||||
package model
|
||||
|
||||
// Protocol values Prizma recognizes. Anything else is passed through as-is
|
||||
// with Protocol set to the raw scheme.
|
||||
const (
|
||||
ProtoVLESS = "vless"
|
||||
ProtoVMess = "vmess"
|
||||
ProtoTrojan = "trojan"
|
||||
ProtoShadowsock = "ss"
|
||||
ProtoSSR = "ssr"
|
||||
ProtoHysteria = "hysteria"
|
||||
ProtoHysteria2 = "hysteria2"
|
||||
ProtoTUIC = "tuic"
|
||||
ProtoAnyTLS = "anytls"
|
||||
ProtoWireGuard = "wireguard"
|
||||
ProtoSOCKS = "socks"
|
||||
ProtoHTTP = "http"
|
||||
ProtoSSH = "ssh"
|
||||
)
|
||||
|
||||
// Node is one proxy server, normalized across every supported format.
|
||||
type Node struct {
|
||||
// Identity
|
||||
Name string `json:"name"`
|
||||
Protocol string `json:"protocol"`
|
||||
Server string `json:"server"`
|
||||
Port int `json:"port"`
|
||||
|
||||
// Credentials. UUID carries vless/vmess ids and tuic uuid; Password carries
|
||||
// trojan/ss/hysteria2/tuic passwords. Both may be set.
|
||||
UUID string `json:"uuid,omitempty"`
|
||||
Password string `json:"password,omitempty"`
|
||||
Method string `json:"method,omitempty"` // shadowsocks cipher
|
||||
AlterID int `json:"alter_id,omitempty"` // vmess aid
|
||||
|
||||
// Transport
|
||||
Network string `json:"network,omitempty"` // tcp|ws|grpc|http|h2|httpupgrade|xhttp|quic|kcp
|
||||
Path string `json:"path,omitempty"`
|
||||
Host string `json:"host,omitempty"`
|
||||
ServiceName string `json:"service_name,omitempty"` // grpc
|
||||
HeaderType string `json:"header_type,omitempty"` // tcp header obfs / kcp seed type
|
||||
Seed string `json:"seed,omitempty"`
|
||||
|
||||
// Security
|
||||
Security string `json:"security,omitempty"` // none|tls|reality
|
||||
SNI string `json:"sni,omitempty"`
|
||||
ALPN []string `json:"alpn,omitempty"`
|
||||
Fingerprint string `json:"fingerprint,omitempty"`
|
||||
PublicKey string `json:"public_key,omitempty"` // reality pbk
|
||||
ShortID string `json:"short_id,omitempty"`
|
||||
SpiderX string `json:"spider_x,omitempty"`
|
||||
Flow string `json:"flow,omitempty"`
|
||||
AllowInsecure bool `json:"allow_insecure,omitempty"`
|
||||
|
||||
// UDP-ish protocols
|
||||
Obfs string `json:"obfs,omitempty"`
|
||||
ObfsPassword string `json:"obfs_password,omitempty"`
|
||||
UpMbps int `json:"up_mbps,omitempty"`
|
||||
DownMbps int `json:"down_mbps,omitempty"`
|
||||
Congestion string `json:"congestion,omitempty"`
|
||||
|
||||
// Extra keeps format-specific leftovers that have no normalized home.
|
||||
Extra map[string]any `json:"extra,omitempty"`
|
||||
|
||||
// Raw payloads — see the fidelity rule in the package doc. Exactly one of
|
||||
// these is populated by a parser, matching the format it parsed.
|
||||
RawURI string `json:"-"`
|
||||
RawClash map[string]any `json:"-"`
|
||||
RawSingBox map[string]any `json:"-"`
|
||||
RawXray map[string]any `json:"-"`
|
||||
|
||||
// RawDoc is the enclosing document the node was extracted from, when the
|
||||
// format has one: the Clash YAML root, the sing-box root, or — for a
|
||||
// Happ-style array of whole Xray configs — that node's own config object.
|
||||
// Renderers use the FIRST surviving node's RawDoc as the document template
|
||||
// so dns/routing/rules blocks the upstream shipped are not lost.
|
||||
RawDoc map[string]any `json:"-"`
|
||||
|
||||
// Provenance — filled by the group assembler, never by parsers.
|
||||
SourceID int64 `json:"source_id,omitempty"`
|
||||
SourceName string `json:"source_name,omitempty"`
|
||||
}
|
||||
|
||||
// Clone returns a deep-enough copy: the maps and slices that renderers mutate
|
||||
// are duplicated, so a rendered node can be relabelled without touching the
|
||||
// cache entry it came from.
|
||||
func (n Node) Clone() Node {
|
||||
c := n
|
||||
if n.ALPN != nil {
|
||||
c.ALPN = append([]string(nil), n.ALPN...)
|
||||
}
|
||||
c.Extra = cloneMap(n.Extra)
|
||||
c.RawClash = cloneMap(n.RawClash)
|
||||
c.RawSingBox = cloneMap(n.RawSingBox)
|
||||
c.RawXray = cloneMap(n.RawXray)
|
||||
c.RawDoc = cloneMap(n.RawDoc)
|
||||
return c
|
||||
}
|
||||
|
||||
func cloneMap(m map[string]any) map[string]any {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]any, len(m))
|
||||
for k, v := range m {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/omar/prizma/internal/model"
|
||||
)
|
||||
|
||||
const clientColumns = `id, hwid, synthetic, user_agent, ip, device_os, last_link, label,
|
||||
banned, allowed, hits, first_seen, last_seen, last_status`
|
||||
|
||||
// ClientQuery drives ListClients.
|
||||
type ClientQuery struct {
|
||||
Search string // matches hwid/ua/ip/label
|
||||
Banned *bool
|
||||
Allowed *bool
|
||||
Limit int
|
||||
Offset int
|
||||
Sort string // "last_seen" (default) | "first_seen" | "hits"
|
||||
}
|
||||
|
||||
// TouchClient records that a device just used a public link: it upserts by
|
||||
// HWID and bumps the hit counter.
|
||||
//
|
||||
// Administrative state — banned, allowed, label — is never overwritten here.
|
||||
// A banned device hitting the link again must stay banned, and the gate reads
|
||||
// exactly this row.
|
||||
func (s *Store) TouchClient(c model.Client) (*model.Client, error) {
|
||||
hwid := strings.ToLower(strings.TrimSpace(c.HWID))
|
||||
if hwid == "" {
|
||||
return nil, errors.New("store: touch client: empty hwid")
|
||||
}
|
||||
ts := unix(now())
|
||||
|
||||
var out *model.Client
|
||||
err := s.write(func(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`INSERT INTO clients
|
||||
(hwid, synthetic, user_agent, ip, device_os, last_link, label, banned,
|
||||
allowed, hits, first_seen, last_seen, last_status)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,1,?,?,?)
|
||||
ON CONFLICT(hwid) DO UPDATE SET
|
||||
synthetic = excluded.synthetic,
|
||||
user_agent = CASE WHEN excluded.user_agent <> '' THEN excluded.user_agent ELSE clients.user_agent END,
|
||||
ip = CASE WHEN excluded.ip <> '' THEN excluded.ip ELSE clients.ip END,
|
||||
device_os = CASE WHEN excluded.device_os <> '' THEN excluded.device_os ELSE clients.device_os END,
|
||||
last_link = CASE WHEN excluded.last_link <> '' THEN excluded.last_link ELSE clients.last_link END,
|
||||
last_status = CASE WHEN excluded.last_status <> 0 THEN excluded.last_status ELSE clients.last_status END,
|
||||
hits = clients.hits + 1,
|
||||
last_seen = excluded.last_seen`,
|
||||
hwid, boolInt(c.Synthetic), truncate(c.UserAgent, 512), truncate(c.IP, 64),
|
||||
truncate(c.DeviceOS, 128), truncate(c.LastLink, 256), truncate(c.Label, 256),
|
||||
boolInt(c.Banned), boolInt(c.Allowed), ts, ts, c.LastStatus)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: touch client: %w", err)
|
||||
}
|
||||
row := tx.QueryRow(`SELECT `+clientColumns+` FROM clients WHERE hwid = ?`, hwid)
|
||||
out, err = scanClient(row)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// LookupClient returns the stored client for hwid, or ErrNotFound. This is the
|
||||
// authoritative read for the access gate.
|
||||
func (s *Store) LookupClient(hwid string) (*model.Client, error) {
|
||||
hwid = strings.ToLower(strings.TrimSpace(hwid))
|
||||
if hwid == "" {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
row := s.db.QueryRow(`SELECT `+clientColumns+` FROM clients WHERE hwid = ?`, hwid)
|
||||
c, err := scanClient(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return c, err
|
||||
}
|
||||
|
||||
// ListClients returns a page of clients plus the total number of rows matching
|
||||
// the query (before Limit/Offset), which is what the UI needs for paging.
|
||||
func (s *Store) ListClients(q ClientQuery) ([]model.Client, int, error) {
|
||||
where, args := clientWhere(q)
|
||||
|
||||
var total int
|
||||
if err := s.db.QueryRow(`SELECT COUNT(*) FROM clients`+where, args...).Scan(&total); err != nil {
|
||||
return nil, 0, fmt.Errorf("store: count clients: %w", err)
|
||||
}
|
||||
|
||||
limit := q.Limit
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
if limit > 1000 {
|
||||
limit = 1000
|
||||
}
|
||||
offset := q.Offset
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
query := `SELECT ` + clientColumns + ` FROM clients` + where +
|
||||
` ORDER BY ` + clientOrder(q.Sort) + ` LIMIT ? OFFSET ?`
|
||||
rows, err := s.db.Query(query, append(append([]any{}, args...), limit, offset)...)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("store: list clients: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []model.Client{}
|
||||
for rows.Next() {
|
||||
c, err := scanClient(rows)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out = append(out, *c)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return out, total, nil
|
||||
}
|
||||
|
||||
// SetClientBanned bans or unbans a device. A ban beats everything, including
|
||||
// whitelist membership.
|
||||
func (s *Store) SetClientBanned(id int64, banned bool) error {
|
||||
return s.updateClient(id, `UPDATE clients SET banned = ? WHERE id = ?`, boolInt(banned))
|
||||
}
|
||||
|
||||
// SetClientAllowed adds or removes a device from the whitelist.
|
||||
func (s *Store) SetClientAllowed(id int64, allowed bool) error {
|
||||
return s.updateClient(id, `UPDATE clients SET allowed = ? WHERE id = ?`, boolInt(allowed))
|
||||
}
|
||||
|
||||
// SetClientLabel names a device for the operator's benefit.
|
||||
func (s *Store) SetClientLabel(id int64, label string) error {
|
||||
return s.updateClient(id, `UPDATE clients SET label = ? WHERE id = ?`, truncate(strings.TrimSpace(label), 256))
|
||||
}
|
||||
|
||||
// DeleteClient forgets a device. It reappears (with zeroed counters and no
|
||||
// ban) the next time it fetches a link, which is why bans should not be
|
||||
// enforced by deleting.
|
||||
func (s *Store) DeleteClient(id int64) error {
|
||||
return s.write(func(tx *sql.Tx) error {
|
||||
res, err := tx.Exec(`DELETE FROM clients WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: delete client: %w", err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// WhitelistCount reports how many devices are whitelisted. Zero means the
|
||||
// whitelist is inactive and every non-banned device passes the gate.
|
||||
func (s *Store) WhitelistCount() (int, error) {
|
||||
var n int
|
||||
if err := s.db.QueryRow(`SELECT COUNT(*) FROM clients WHERE allowed = 1`).Scan(&n); err != nil {
|
||||
return 0, fmt.Errorf("store: whitelist count: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (s *Store) updateClient(id int64, query string, arg any) error {
|
||||
return s.write(func(tx *sql.Tx) error {
|
||||
res, err := tx.Exec(query, arg, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: update client: %w", err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func clientWhere(q ClientQuery) (string, []any) {
|
||||
var conds []string
|
||||
var args []any
|
||||
|
||||
if s := strings.TrimSpace(q.Search); s != "" {
|
||||
// LIKE with escaped wildcards: an operator pasting a HWID with an
|
||||
// underscore must not match everything.
|
||||
pat := "%" + escapeLike(strings.ToLower(s)) + "%"
|
||||
conds = append(conds, `(LOWER(hwid) LIKE ? ESCAPE '\' OR LOWER(user_agent) LIKE ? ESCAPE '\' OR LOWER(ip) LIKE ? ESCAPE '\' OR LOWER(label) LIKE ? ESCAPE '\')`)
|
||||
args = append(args, pat, pat, pat, pat)
|
||||
}
|
||||
if q.Banned != nil {
|
||||
conds = append(conds, `banned = ?`)
|
||||
args = append(args, boolInt(*q.Banned))
|
||||
}
|
||||
if q.Allowed != nil {
|
||||
conds = append(conds, `allowed = ?`)
|
||||
args = append(args, boolInt(*q.Allowed))
|
||||
}
|
||||
if len(conds) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
return " WHERE " + strings.Join(conds, " AND "), args
|
||||
}
|
||||
|
||||
func clientOrder(sort string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(strings.TrimPrefix(sort, "-"))) {
|
||||
case "first_seen", "firstseen", "created":
|
||||
return "first_seen DESC, id DESC"
|
||||
case "hits":
|
||||
return "hits DESC, last_seen DESC"
|
||||
case "hwid":
|
||||
return "hwid ASC"
|
||||
default:
|
||||
return "last_seen DESC, id DESC"
|
||||
}
|
||||
}
|
||||
|
||||
func escapeLike(s string) string {
|
||||
r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
|
||||
return r.Replace(s)
|
||||
}
|
||||
|
||||
func scanClient(sc rowScanner) (*model.Client, error) {
|
||||
var (
|
||||
c model.Client
|
||||
synthetic int
|
||||
banned int
|
||||
allowed int
|
||||
firstSeen int64
|
||||
lastSeen int64
|
||||
)
|
||||
err := sc.Scan(&c.ID, &c.HWID, &synthetic, &c.UserAgent, &c.IP, &c.DeviceOS,
|
||||
&c.LastLink, &c.Label, &banned, &allowed, &c.Hits, &firstSeen, &lastSeen, &c.LastStatus)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.Synthetic = synthetic != 0
|
||||
c.Banned = banned != 0
|
||||
c.Allowed = allowed != 0
|
||||
c.FirstSeen = fromUnix(firstSeen)
|
||||
c.LastSeen = fromUnix(lastSeen)
|
||||
return &c, nil
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/omar/prizma/internal/model"
|
||||
)
|
||||
|
||||
const groupColumns = `id, name, token, members, filter, output_format, name_template,
|
||||
dedupe, enabled, note, created_at, updated_at, hits`
|
||||
|
||||
// ListGroups returns every group with its member names refreshed from the
|
||||
// sources table.
|
||||
func (s *Store) ListGroups() ([]model.Group, error) {
|
||||
rows, err := s.db.Query(`SELECT ` + groupColumns + ` FROM "groups" ORDER BY id ASC`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list groups: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []model.Group{}
|
||||
for rows.Next() {
|
||||
g, err := scanGroup(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *g)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names, err := s.sourceNames()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range out {
|
||||
fillMemberNames(&out[i], names)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetGroup returns one group by id, or ErrNotFound.
|
||||
func (s *Store) GetGroup(id int64) (*model.Group, error) {
|
||||
row := s.db.QueryRow(`SELECT `+groupColumns+` FROM "groups" WHERE id = ?`, id)
|
||||
return s.getGroupRow(row)
|
||||
}
|
||||
|
||||
// GetGroupByToken resolves a public link slug to its group, or ErrNotFound.
|
||||
func (s *Store) GetGroupByToken(tok string) (*model.Group, error) {
|
||||
row := s.db.QueryRow(`SELECT `+groupColumns+` FROM "groups" WHERE token = ?`, strings.TrimSpace(tok))
|
||||
return s.getGroupRow(row)
|
||||
}
|
||||
|
||||
func (s *Store) getGroupRow(row *sql.Row) (*model.Group, error) {
|
||||
g, err := scanGroup(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names, err := s.sourceNames()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fillMemberNames(g, names)
|
||||
return g, nil
|
||||
}
|
||||
|
||||
// CreateGroup inserts g and fills in ID and Token.
|
||||
func (s *Store) CreateGroup(g *model.Group) error {
|
||||
if g == nil {
|
||||
return errors.New("store: nil group")
|
||||
}
|
||||
if err := normalizeGroup(g); err != nil {
|
||||
return err
|
||||
}
|
||||
members, filter, err := marshalGroupJSON(g)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ts := now()
|
||||
g.CreatedAt, g.UpdatedAt = ts, ts
|
||||
|
||||
return s.write(func(tx *sql.Tx) error {
|
||||
tok := g.Token
|
||||
if tok != "" {
|
||||
if err := claimToken(tx, tok, string(model.LinkGroup), 0); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
tok, err = claimGeneratedToken(tx, string(model.LinkGroup), 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
res, err := tx.Exec(`INSERT INTO "groups"
|
||||
(name, token, members, filter, output_format, name_template, dedupe,
|
||||
enabled, note, created_at, updated_at, hits)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,0)`,
|
||||
g.Name, tok, members, filter, g.OutputFormat, g.NameTemplate,
|
||||
boolInt(g.Dedupe), boolInt(g.Enabled), g.Note, unix(ts), unix(ts))
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: insert group: %w", err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(`UPDATE tokens SET ref_id = ? WHERE token = ?`, id, tok); err != nil {
|
||||
return err
|
||||
}
|
||||
g.ID, g.Token = id, tok
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateGroup writes every editable field of g; an empty Token keeps the
|
||||
// published one.
|
||||
func (s *Store) UpdateGroup(g *model.Group) error {
|
||||
if g == nil {
|
||||
return errors.New("store: nil group")
|
||||
}
|
||||
if g.ID == 0 {
|
||||
return errors.New("store: update group: missing id")
|
||||
}
|
||||
if err := normalizeGroup(g); err != nil {
|
||||
return err
|
||||
}
|
||||
members, filter, err := marshalGroupJSON(g)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ts := now()
|
||||
|
||||
return s.write(func(tx *sql.Tx) error {
|
||||
var curToken string
|
||||
err := tx.QueryRow(`SELECT token FROM "groups" WHERE id = ?`, g.ID).Scan(&curToken)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
token := curToken
|
||||
if g.Token != "" && g.Token != curToken {
|
||||
if err := claimToken(tx, g.Token, string(model.LinkGroup), g.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
token = g.Token
|
||||
}
|
||||
if _, err := tx.Exec(`UPDATE "groups" SET
|
||||
name = ?, token = ?, members = ?, filter = ?, output_format = ?,
|
||||
name_template = ?, dedupe = ?, enabled = ?, note = ?, updated_at = ?
|
||||
WHERE id = ?`,
|
||||
g.Name, token, members, filter, g.OutputFormat, g.NameTemplate,
|
||||
boolInt(g.Dedupe), boolInt(g.Enabled), g.Note, unix(ts), g.ID); err != nil {
|
||||
return fmt.Errorf("store: update group: %w", err)
|
||||
}
|
||||
if token != curToken {
|
||||
if err := releaseToken(tx, curToken); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
g.Token, g.UpdatedAt = token, ts
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteGroup removes the group and frees its link token.
|
||||
func (s *Store) DeleteGroup(id int64) error {
|
||||
return s.write(func(tx *sql.Tx) error {
|
||||
var token string
|
||||
err := tx.QueryRow(`SELECT token FROM "groups" WHERE id = ?`, id).Scan(&token)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(`DELETE FROM "groups" WHERE id = ?`, id); err != nil {
|
||||
return err
|
||||
}
|
||||
return releaseToken(tx, token)
|
||||
})
|
||||
}
|
||||
|
||||
// --- helpers ----------------------------------------------------------------
|
||||
|
||||
func scanGroup(sc rowScanner) (*model.Group, error) {
|
||||
var (
|
||||
g model.Group
|
||||
members string
|
||||
filter string
|
||||
dedupe int
|
||||
enabled int
|
||||
createdAt int64
|
||||
updatedAt int64
|
||||
)
|
||||
err := sc.Scan(&g.ID, &g.Name, &g.Token, &members, &filter, &g.OutputFormat,
|
||||
&g.NameTemplate, &dedupe, &enabled, &g.Note, &createdAt, &updatedAt, &g.Hits)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g.Dedupe = dedupe != 0
|
||||
g.Enabled = enabled != 0
|
||||
g.CreatedAt = fromUnix(createdAt)
|
||||
g.UpdatedAt = fromUnix(updatedAt)
|
||||
g.Members = []model.GroupMember{}
|
||||
if members != "" && members != "null" {
|
||||
// A member list that somehow got corrupted must not make the whole
|
||||
// group unreadable in the UI; treat it as empty.
|
||||
if err := json.Unmarshal([]byte(members), &g.Members); err != nil {
|
||||
g.Members = []model.GroupMember{}
|
||||
}
|
||||
}
|
||||
if filter != "" && filter != "null" {
|
||||
_ = json.Unmarshal([]byte(filter), &g.Filter)
|
||||
}
|
||||
return &g, nil
|
||||
}
|
||||
|
||||
func normalizeGroup(g *model.Group) error {
|
||||
g.Name = strings.TrimSpace(g.Name)
|
||||
g.Token = strings.TrimSpace(g.Token)
|
||||
g.OutputFormat = strings.ToLower(strings.TrimSpace(g.OutputFormat))
|
||||
if g.Name == "" {
|
||||
return errors.New("store: group name is required")
|
||||
}
|
||||
if g.Token != "" && !validToken(g.Token) {
|
||||
return fmt.Errorf("store: invalid token %q", g.Token)
|
||||
}
|
||||
// Drop members that point nowhere; they would only produce fetch errors.
|
||||
kept := make([]model.GroupMember, 0, len(g.Members))
|
||||
for _, m := range g.Members {
|
||||
if m.SourceID > 0 {
|
||||
kept = append(kept, m)
|
||||
}
|
||||
}
|
||||
g.Members = kept
|
||||
return nil
|
||||
}
|
||||
|
||||
func marshalGroupJSON(g *model.Group) (members string, filter string, err error) {
|
||||
m := g.Members
|
||||
if m == nil {
|
||||
m = []model.GroupMember{}
|
||||
}
|
||||
mb, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("store: encode group members: %w", err)
|
||||
}
|
||||
fb, err := json.Marshal(g.Filter)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("store: encode group filter: %w", err)
|
||||
}
|
||||
return string(mb), string(fb), nil
|
||||
}
|
||||
|
||||
func (s *Store) sourceNames() (map[int64]string, error) {
|
||||
rows, err := s.db.Query(`SELECT id, name FROM sources`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: source names: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
names := map[int64]string{}
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var name string
|
||||
if err := rows.Scan(&id, &name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names[id] = name
|
||||
}
|
||||
return names, rows.Err()
|
||||
}
|
||||
|
||||
// fillMemberNames refreshes the denormalized member names so a renamed source
|
||||
// does not show up under its old name in the group editor.
|
||||
func fillMemberNames(g *model.Group, names map[int64]string) {
|
||||
for i := range g.Members {
|
||||
if n, ok := names[g.Members[i].SourceID]; ok {
|
||||
g.Members[i].Name = n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// dropSourceFromGroups rewrites every group's member JSON without sourceID.
|
||||
func dropSourceFromGroups(tx *sql.Tx, sourceID int64) error {
|
||||
rows, err := tx.Query(`SELECT id, members FROM "groups"`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
type pending struct {
|
||||
id int64
|
||||
members string
|
||||
}
|
||||
var updates []pending
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var raw string
|
||||
if err := rows.Scan(&id, &raw); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
var members []model.GroupMember
|
||||
if err := json.Unmarshal([]byte(raw), &members); err != nil {
|
||||
continue
|
||||
}
|
||||
kept := make([]model.GroupMember, 0, len(members))
|
||||
for _, m := range members {
|
||||
if m.SourceID != sourceID {
|
||||
kept = append(kept, m)
|
||||
}
|
||||
}
|
||||
if len(kept) == len(members) {
|
||||
continue
|
||||
}
|
||||
b, err := json.Marshal(kept)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
updates = append(updates, pending{id: id, members: string(b)})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
for _, u := range updates {
|
||||
if _, err := tx.Exec(`UPDATE "groups" SET members = ?, updated_at = ? WHERE id = ?`,
|
||||
u.members, unix(now()), u.id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/omar/prizma/internal/model"
|
||||
)
|
||||
|
||||
// Setting returns the stored value for key, or "" when the key was never set.
|
||||
// A missing key is not an error: callers bootstrap defaults (the JWT secret,
|
||||
// for instance) from an empty answer.
|
||||
func (s *Store) Setting(key string) (string, error) {
|
||||
var v string
|
||||
err := s.db.QueryRow(`SELECT value FROM settings WHERE key = ?`, key).Scan(&v)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("store: read setting %q: %w", key, err)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// SetSetting stores value under key, replacing any previous value.
|
||||
func (s *Store) SetSetting(key, value string) error {
|
||||
if strings.TrimSpace(key) == "" {
|
||||
return errors.New("store: empty setting key")
|
||||
}
|
||||
return s.write(func(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`INSERT INTO settings (key, value) VALUES (?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`, key, value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: write setting %q: %w", key, err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// Stats is the dashboard payload: counts plus request volume.
|
||||
func (s *Store) Stats() (model.Stats, error) {
|
||||
var st model.Stats
|
||||
row := s.db.QueryRow(`SELECT
|
||||
(SELECT COUNT(*) FROM sources),
|
||||
(SELECT COUNT(*) FROM "groups"),
|
||||
(SELECT COUNT(*) FROM clients),
|
||||
(SELECT COUNT(*) FROM clients WHERE banned = 1),
|
||||
(SELECT COUNT(*) FROM clients WHERE allowed = 1),
|
||||
(SELECT COUNT(*) FROM requests WHERE ts >= ?)`, unix(now().Add(-24*time.Hour)))
|
||||
if err := row.Scan(&st.Sources, &st.Groups, &st.Clients, &st.BannedClients,
|
||||
&st.Whitelisted, &st.Requests24h); err != nil {
|
||||
return st, fmt.Errorf("store: stats: %w", err)
|
||||
}
|
||||
|
||||
// The request log is pruned after 30 days, so the lifetime total lives in
|
||||
// its own counter instead of being COUNT(*)ed.
|
||||
raw, err := s.Setting(settingTotalRequests)
|
||||
if err != nil {
|
||||
return st, err
|
||||
}
|
||||
if raw != "" {
|
||||
st.TotalRequests, _ = strconv.ParseInt(raw, 10, 64)
|
||||
}
|
||||
if st.TotalRequests < st.Requests24h {
|
||||
st.TotalRequests = st.Requests24h
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// LogRequest appends one public-link request to the log, bumps the lifetime
|
||||
// counter and the hit count of the link it named.
|
||||
//
|
||||
// link may be a bare token, a "/sub/{token}/..." path, or a "source:{id}" /
|
||||
// "group:{id}" reference — whatever the handler finds convenient.
|
||||
func (s *Store) LogRequest(link string, hwid string, status int) error {
|
||||
ts := now()
|
||||
prune := s.logN.Add(1)%pruneEvery == 1
|
||||
|
||||
return s.write(func(tx *sql.Tx) error {
|
||||
if _, err := tx.Exec(`INSERT INTO requests (ts, link, hwid, status) VALUES (?,?,?,?)`,
|
||||
unix(ts), truncate(link, 256), truncate(strings.ToLower(strings.TrimSpace(hwid)), 128), status); err != nil {
|
||||
return fmt.Errorf("store: log request: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(`INSERT INTO settings (key, value) VALUES (?, '1')
|
||||
ON CONFLICT(key) DO UPDATE SET value = CAST(CAST(settings.value AS INTEGER) + 1 AS TEXT)`,
|
||||
settingTotalRequests); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := bumpLinkHits(tx, link); err != nil {
|
||||
return err
|
||||
}
|
||||
if prune {
|
||||
// One indexed DELETE keeps the log from growing without bound.
|
||||
if _, err := tx.Exec(`DELETE FROM requests WHERE ts < ?`,
|
||||
unix(ts.Add(-requestRetention))); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// bumpLinkHits increments the hit counter of whichever link the request named.
|
||||
// An unknown link is not an error — the log row is the important part.
|
||||
func bumpLinkHits(tx *sql.Tx, link string) error {
|
||||
kind, id, tok := parseLinkRef(link)
|
||||
switch {
|
||||
case kind == string(model.LinkSource) && id > 0:
|
||||
_, err := tx.Exec(`UPDATE sources SET hits = hits + 1 WHERE id = ?`, id)
|
||||
return err
|
||||
case kind == string(model.LinkGroup) && id > 0:
|
||||
_, err := tx.Exec(`UPDATE "groups" SET hits = hits + 1 WHERE id = ?`, id)
|
||||
return err
|
||||
case tok != "":
|
||||
if _, err := tx.Exec(`UPDATE sources SET hits = hits + 1 WHERE token = ?`, tok); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := tx.Exec(`UPDATE "groups" SET hits = hits + 1 WHERE token = ?`, tok)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseLinkRef understands the shapes a handler may pass as "link".
|
||||
func parseLinkRef(link string) (kind string, id int64, token string) {
|
||||
s := strings.TrimSpace(link)
|
||||
if s == "" {
|
||||
return "", 0, ""
|
||||
}
|
||||
if i := strings.IndexAny(s, "?#"); i >= 0 {
|
||||
s = s[:i]
|
||||
}
|
||||
if k, rest, ok := strings.Cut(s, ":"); ok {
|
||||
k = strings.ToLower(k)
|
||||
if k == string(model.LinkSource) || k == string(model.LinkGroup) {
|
||||
if n, err := strconv.ParseInt(strings.TrimSpace(rest), 10, 64); err == nil {
|
||||
return k, n, ""
|
||||
}
|
||||
}
|
||||
}
|
||||
parts := strings.Split(strings.Trim(s, "/"), "/")
|
||||
for i, p := range parts {
|
||||
if strings.EqualFold(p, "sub") {
|
||||
// "/sub/{token}/junk" — and "/sub/" alone names no link at all.
|
||||
if i+1 < len(parts) && validToken(parts[i+1]) {
|
||||
return "", 0, parts[i+1]
|
||||
}
|
||||
return "", 0, ""
|
||||
}
|
||||
}
|
||||
if len(parts) == 1 && validToken(parts[0]) {
|
||||
return "", 0, parts[0]
|
||||
}
|
||||
return "", 0, ""
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/omar/prizma/internal/model"
|
||||
)
|
||||
|
||||
const sourceColumns = `id, name, url, hwid, device_os, ver_os, device_model, user_agent,
|
||||
forward_client_ua, extra_headers, cache_ttl, enabled, token, note,
|
||||
created_at, updated_at, last_fetch_at, last_fetch_ok, last_fetch_error,
|
||||
last_node_count, hits`
|
||||
|
||||
// ListSources returns every source, newest link first by name for a stable UI
|
||||
// ordering.
|
||||
func (s *Store) ListSources() ([]model.Source, error) {
|
||||
rows, err := s.db.Query(`SELECT ` + sourceColumns + ` FROM sources ORDER BY id ASC`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list sources: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []model.Source{}
|
||||
for rows.Next() {
|
||||
src, err := scanSource(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *src)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetSource returns one source by id, or ErrNotFound.
|
||||
func (s *Store) GetSource(id int64) (*model.Source, error) {
|
||||
row := s.db.QueryRow(`SELECT `+sourceColumns+` FROM sources WHERE id = ?`, id)
|
||||
src, err := scanSource(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return src, err
|
||||
}
|
||||
|
||||
// GetSourceByToken resolves a public link slug to its source, or ErrNotFound.
|
||||
func (s *Store) GetSourceByToken(tok string) (*model.Source, error) {
|
||||
row := s.db.QueryRow(`SELECT `+sourceColumns+` FROM sources WHERE token = ?`, strings.TrimSpace(tok))
|
||||
src, err := scanSource(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return src, err
|
||||
}
|
||||
|
||||
// CreateSource inserts src and fills in ID, Token and HWID.
|
||||
//
|
||||
// An empty HWID gets a fresh one — that value is what Prizma presents to the
|
||||
// upstream panel, and it is the only thing standing between one upstream seat
|
||||
// and any number of client devices.
|
||||
func (s *Store) CreateSource(src *model.Source) error {
|
||||
if src == nil {
|
||||
return errors.New("store: nil source")
|
||||
}
|
||||
if err := normalizeSource(src); err != nil {
|
||||
return err
|
||||
}
|
||||
if src.HWID == "" {
|
||||
src.HWID = newHWID()
|
||||
}
|
||||
if src.Token != "" && !validToken(src.Token) {
|
||||
return fmt.Errorf("store: invalid token %q", src.Token)
|
||||
}
|
||||
|
||||
headers, err := marshalHeaders(src.ExtraHeaders)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ts := now()
|
||||
src.CreatedAt, src.UpdatedAt = ts, ts
|
||||
|
||||
return s.write(func(tx *sql.Tx) error {
|
||||
// The row must exist before the token can point at it, but the token
|
||||
// column is NOT NULL — insert with a placeholder claimed up front.
|
||||
tok := src.Token
|
||||
if tok != "" {
|
||||
if err := claimToken(tx, tok, string(model.LinkSource), 0); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
tok, err = claimGeneratedToken(tx, string(model.LinkSource), 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
res, err := tx.Exec(`INSERT INTO sources
|
||||
(name, url, hwid, device_os, ver_os, device_model, user_agent,
|
||||
forward_client_ua, extra_headers, cache_ttl, enabled, token, note,
|
||||
created_at, updated_at, last_fetch_ok, last_fetch_error, last_node_count, hits)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,0,'',0,0)`,
|
||||
src.Name, src.URL, src.HWID, src.DeviceOS, src.VerOS, src.DeviceModel,
|
||||
src.UserAgent, boolInt(src.ForwardClientUA), headers, src.CacheTTL,
|
||||
boolInt(src.Enabled), tok, src.Note, unix(ts), unix(ts))
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: insert source: %w", err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(`UPDATE tokens SET ref_id = ? WHERE token = ?`, id, tok); err != nil {
|
||||
return err
|
||||
}
|
||||
src.ID, src.Token = id, tok
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateSource writes every editable field of src. An empty Token or HWID
|
||||
// keeps the stored one, so a partial update from the UI cannot silently orphan
|
||||
// a published link.
|
||||
func (s *Store) UpdateSource(src *model.Source) error {
|
||||
if src == nil {
|
||||
return errors.New("store: nil source")
|
||||
}
|
||||
if src.ID == 0 {
|
||||
return errors.New("store: update source: missing id")
|
||||
}
|
||||
if err := normalizeSource(src); err != nil {
|
||||
return err
|
||||
}
|
||||
if src.Token != "" && !validToken(src.Token) {
|
||||
return fmt.Errorf("store: invalid token %q", src.Token)
|
||||
}
|
||||
headers, err := marshalHeaders(src.ExtraHeaders)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ts := now()
|
||||
|
||||
return s.write(func(tx *sql.Tx) error {
|
||||
var curToken, curHWID string
|
||||
err := tx.QueryRow(`SELECT token, hwid FROM sources WHERE id = ?`, src.ID).Scan(&curToken, &curHWID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
token := curToken
|
||||
if src.Token != "" && src.Token != curToken {
|
||||
if err := claimToken(tx, src.Token, string(model.LinkSource), src.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
token = src.Token
|
||||
}
|
||||
hwid := src.HWID
|
||||
if hwid == "" {
|
||||
hwid = curHWID
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(`UPDATE sources SET
|
||||
name = ?, url = ?, hwid = ?, device_os = ?, ver_os = ?, device_model = ?,
|
||||
user_agent = ?, forward_client_ua = ?, extra_headers = ?, cache_ttl = ?,
|
||||
enabled = ?, token = ?, note = ?, updated_at = ?
|
||||
WHERE id = ?`,
|
||||
src.Name, src.URL, hwid, src.DeviceOS, src.VerOS, src.DeviceModel,
|
||||
src.UserAgent, boolInt(src.ForwardClientUA), headers, src.CacheTTL,
|
||||
boolInt(src.Enabled), token, src.Note, unix(ts), src.ID); err != nil {
|
||||
return fmt.Errorf("store: update source: %w", err)
|
||||
}
|
||||
if token != curToken {
|
||||
if err := releaseToken(tx, curToken); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
src.Token, src.HWID, src.UpdatedAt = token, hwid, ts
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteSource removes the source, frees its link token and drops it from every
|
||||
// group that referenced it — a group pointing at a dead source would fail to
|
||||
// render forever.
|
||||
func (s *Store) DeleteSource(id int64) error {
|
||||
return s.write(func(tx *sql.Tx) error {
|
||||
var token string
|
||||
err := tx.QueryRow(`SELECT token FROM sources WHERE id = ?`, id).Scan(&token)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(`DELETE FROM sources WHERE id = ?`, id); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := releaseToken(tx, token); err != nil {
|
||||
return err
|
||||
}
|
||||
return dropSourceFromGroups(tx, id)
|
||||
})
|
||||
}
|
||||
|
||||
// RecordSourceFetch stores the outcome of an upstream fetch. On failure the
|
||||
// last known good node count is kept so the UI can still show what the source
|
||||
// used to serve.
|
||||
func (s *Store) RecordSourceFetch(id int64, ok bool, nodes int, errMsg string) error {
|
||||
ts := unix(now())
|
||||
return s.write(func(tx *sql.Tx) error {
|
||||
var res sql.Result
|
||||
var err error
|
||||
if ok {
|
||||
res, err = tx.Exec(`UPDATE sources SET last_fetch_at = ?, last_fetch_ok = 1,
|
||||
last_fetch_error = '', last_node_count = ? WHERE id = ?`, ts, nodes, id)
|
||||
} else {
|
||||
res, err = tx.Exec(`UPDATE sources SET last_fetch_at = ?, last_fetch_ok = 0,
|
||||
last_fetch_error = ? WHERE id = ?`, ts, truncate(errMsg, 1000), id)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: record source fetch: %w", err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// --- helpers ----------------------------------------------------------------
|
||||
|
||||
type rowScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanSource(sc rowScanner) (*model.Source, error) {
|
||||
var (
|
||||
src model.Source
|
||||
headers string
|
||||
forwardUA int
|
||||
enabled int
|
||||
lastFetchAt sql.NullInt64
|
||||
lastOK int
|
||||
createdAt int64
|
||||
updatedAt int64
|
||||
)
|
||||
err := sc.Scan(&src.ID, &src.Name, &src.URL, &src.HWID, &src.DeviceOS, &src.VerOS,
|
||||
&src.DeviceModel, &src.UserAgent, &forwardUA, &headers, &src.CacheTTL,
|
||||
&enabled, &src.Token, &src.Note, &createdAt, &updatedAt, &lastFetchAt,
|
||||
&lastOK, &src.LastFetchError, &src.LastNodeCount, &src.Hits)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
src.ForwardClientUA = forwardUA != 0
|
||||
src.Enabled = enabled != 0
|
||||
src.LastFetchOK = lastOK != 0
|
||||
src.CreatedAt = fromUnix(createdAt)
|
||||
src.UpdatedAt = fromUnix(updatedAt)
|
||||
src.LastFetchAt = nullTime(lastFetchAt)
|
||||
src.ExtraHeaders = unmarshalHeaders(headers)
|
||||
return &src, nil
|
||||
}
|
||||
|
||||
func normalizeSource(src *model.Source) error {
|
||||
src.Name = strings.TrimSpace(src.Name)
|
||||
src.URL = strings.TrimSpace(src.URL)
|
||||
src.HWID = strings.ToLower(strings.TrimSpace(src.HWID))
|
||||
src.Token = strings.TrimSpace(src.Token)
|
||||
src.DeviceOS = strings.TrimSpace(src.DeviceOS)
|
||||
src.VerOS = strings.TrimSpace(src.VerOS)
|
||||
src.DeviceModel = strings.TrimSpace(src.DeviceModel)
|
||||
src.UserAgent = strings.TrimSpace(src.UserAgent)
|
||||
if src.Name == "" {
|
||||
return errors.New("store: source name is required")
|
||||
}
|
||||
if src.URL == "" {
|
||||
return errors.New("store: source url is required")
|
||||
}
|
||||
if len(src.HWID) > 128 {
|
||||
return errors.New("store: source hwid is too long")
|
||||
}
|
||||
if src.CacheTTL < 0 {
|
||||
src.CacheTTL = 0
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func marshalHeaders(h map[string]string) (string, error) {
|
||||
if len(h) == 0 {
|
||||
return "{}", nil
|
||||
}
|
||||
b, err := json.Marshal(h)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("store: encode extra headers: %w", err)
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
func unmarshalHeaders(raw string) map[string]string {
|
||||
if raw == "" || raw == "{}" {
|
||||
return nil
|
||||
}
|
||||
var h map[string]string
|
||||
if err := json.Unmarshal([]byte(raw), &h); err != nil || len(h) == 0 {
|
||||
return nil
|
||||
}
|
||||
return h
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
// Package store is Prizma's persistence layer: a single SQLite database
|
||||
// holding sources, groups, clients, settings and the request log.
|
||||
//
|
||||
// SQLite comes from modernc.org/sqlite — a pure Go translation of the C
|
||||
// library — because the release must cross-compile with CGO_ENABLED=0.
|
||||
//
|
||||
// Concurrency model: one *sql.DB, opened WAL with a 5s busy timeout, and a
|
||||
// process-wide mutex around write transactions. WAL lets readers run while a
|
||||
// writer is active; the mutex means the writer is always alone, so SQLITE_BUSY
|
||||
// can only ever come from another *process* touching the same file, which the
|
||||
// busy timeout then absorbs.
|
||||
package store
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite" // pure Go SQLite driver
|
||||
)
|
||||
|
||||
// TokenLength is the number of characters in a public link slug.
|
||||
const TokenLength = 16
|
||||
|
||||
// ErrNotFound is returned by the Get* helpers when no row matches. It also
|
||||
// reports true for errors.Is(err, sql.ErrNoRows) so callers may test for
|
||||
// either.
|
||||
var ErrNotFound = notFoundError{}
|
||||
|
||||
type notFoundError struct{}
|
||||
|
||||
func (notFoundError) Error() string { return "store: not found" }
|
||||
|
||||
func (notFoundError) Is(target error) bool { return target == sql.ErrNoRows }
|
||||
|
||||
// ErrTokenTaken is returned when an explicitly supplied token already belongs
|
||||
// to another source or group. Tokens live in ONE namespace because /sub/{token}
|
||||
// resolves either kind.
|
||||
var ErrTokenTaken = errors.New("store: token already in use")
|
||||
|
||||
// requestRetention bounds the request log; rows older than this are pruned
|
||||
// during writes.
|
||||
const requestRetention = 30 * 24 * time.Hour
|
||||
|
||||
// pruneEvery is how often (in LogRequest calls) the retention sweep runs. The
|
||||
// sweep is a single indexed DELETE, but there is no reason to pay for it on
|
||||
// every request.
|
||||
const pruneEvery = 256
|
||||
|
||||
// settingTotalRequests keeps a monotonic request counter that survives log
|
||||
// pruning, so the dashboard total does not shrink after 30 days.
|
||||
const settingTotalRequests = "stats.total_requests"
|
||||
|
||||
// Store owns the database handle. Create it with Open.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
path string
|
||||
|
||||
wmu sync.Mutex // serializes write transactions, see package doc
|
||||
logN atomic.Uint64
|
||||
}
|
||||
|
||||
// Open opens (creating it if needed) the SQLite database at path and brings
|
||||
// its schema up to date. The parent directory is created as well.
|
||||
//
|
||||
// path may be ":memory:" (or "file::memory:...") for a throwaway database; the
|
||||
// pool is then pinned to one connection because every extra connection would
|
||||
// otherwise get its own private empty database.
|
||||
func Open(path string) (*Store, error) {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
path = ":memory:"
|
||||
}
|
||||
memory := isMemoryPath(path)
|
||||
if !memory {
|
||||
if dir := filepath.Dir(path); dir != "" && dir != "." {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("store: create db directory: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The driver only parses the query string; the part before "?" is handed to
|
||||
// sqlite verbatim, so Windows paths with backslashes and drive letters need
|
||||
// no escaping.
|
||||
dsn := path + "?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)"
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: open %s: %w", path, err)
|
||||
}
|
||||
if memory {
|
||||
db.SetMaxOpenConns(1)
|
||||
} else {
|
||||
db.SetMaxOpenConns(8)
|
||||
}
|
||||
db.SetMaxIdleConns(4)
|
||||
db.SetConnMaxIdleTime(5 * time.Minute)
|
||||
|
||||
if err := db.Ping(); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("store: ping %s: %w", path, err)
|
||||
}
|
||||
|
||||
s := &Store{db: db, path: path}
|
||||
if err := s.migrate(); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Close releases the database handle.
|
||||
func (s *Store) Close() error {
|
||||
if s == nil || s.db == nil {
|
||||
return nil
|
||||
}
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
// DB exposes the handle for packages that need a raw query (health checks,
|
||||
// backups). Writers should go through the Store methods so they are serialized.
|
||||
func (s *Store) DB() *sql.DB { return s.db }
|
||||
|
||||
func isMemoryPath(p string) bool {
|
||||
return p == ":memory:" || strings.Contains(p, ":memory:") || strings.Contains(p, "mode=memory")
|
||||
}
|
||||
|
||||
// migrations are applied in order; PRAGMA user_version records how far we got.
|
||||
// Never edit an applied migration — append a new one.
|
||||
var migrations = []string{
|
||||
// 1: initial schema
|
||||
`
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- tokens is the single namespace shared by source and group links: /sub/{token}
|
||||
-- resolves either kind, so a collision between the two must be impossible.
|
||||
CREATE TABLE IF NOT EXISTS tokens (
|
||||
token TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL,
|
||||
ref_id INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sources (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
hwid TEXT NOT NULL,
|
||||
device_os TEXT NOT NULL DEFAULT '',
|
||||
ver_os TEXT NOT NULL DEFAULT '',
|
||||
device_model TEXT NOT NULL DEFAULT '',
|
||||
user_agent TEXT NOT NULL DEFAULT '',
|
||||
forward_client_ua INTEGER NOT NULL DEFAULT 0,
|
||||
extra_headers TEXT NOT NULL DEFAULT '{}',
|
||||
cache_ttl INTEGER NOT NULL DEFAULT 0,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
token TEXT NOT NULL UNIQUE REFERENCES tokens(token) ON UPDATE CASCADE,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
last_fetch_at INTEGER,
|
||||
last_fetch_ok INTEGER NOT NULL DEFAULT 0,
|
||||
last_fetch_error TEXT NOT NULL DEFAULT '',
|
||||
last_node_count INTEGER NOT NULL DEFAULT 0,
|
||||
hits INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS groups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
token TEXT NOT NULL UNIQUE REFERENCES tokens(token) ON UPDATE CASCADE,
|
||||
members TEXT NOT NULL DEFAULT '[]',
|
||||
filter TEXT NOT NULL DEFAULT '{}',
|
||||
output_format TEXT NOT NULL DEFAULT '',
|
||||
name_template TEXT NOT NULL DEFAULT '',
|
||||
dedupe INTEGER NOT NULL DEFAULT 0,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
hits INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS clients (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
hwid TEXT NOT NULL UNIQUE,
|
||||
synthetic INTEGER NOT NULL DEFAULT 0,
|
||||
user_agent TEXT NOT NULL DEFAULT '',
|
||||
ip TEXT NOT NULL DEFAULT '',
|
||||
device_os TEXT NOT NULL DEFAULT '',
|
||||
last_link TEXT NOT NULL DEFAULT '',
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
banned INTEGER NOT NULL DEFAULT 0,
|
||||
allowed INTEGER NOT NULL DEFAULT 0,
|
||||
hits INTEGER NOT NULL DEFAULT 0,
|
||||
first_seen INTEGER NOT NULL,
|
||||
last_seen INTEGER NOT NULL,
|
||||
last_status INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_clients_last_seen ON clients(last_seen DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_clients_first_seen ON clients(first_seen DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_clients_banned ON clients(banned);
|
||||
CREATE INDEX IF NOT EXISTS idx_clients_allowed ON clients(allowed);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS requests (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts INTEGER NOT NULL,
|
||||
link TEXT NOT NULL DEFAULT '',
|
||||
hwid TEXT NOT NULL DEFAULT '',
|
||||
status INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_requests_ts ON requests(ts DESC);
|
||||
`,
|
||||
}
|
||||
|
||||
func (s *Store) migrate() error {
|
||||
var version int
|
||||
if err := s.db.QueryRow(`PRAGMA user_version`).Scan(&version); err != nil {
|
||||
return fmt.Errorf("store: read schema version: %w", err)
|
||||
}
|
||||
if version > len(migrations) {
|
||||
return fmt.Errorf("store: database schema version %d is newer than this binary understands (%d)", version, len(migrations))
|
||||
}
|
||||
|
||||
s.wmu.Lock()
|
||||
defer s.wmu.Unlock()
|
||||
for i := version; i < len(migrations); i++ {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: begin migration %d: %w", i+1, err)
|
||||
}
|
||||
if _, err := tx.Exec(migrations[i]); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("store: migration %d: %w", i+1, err)
|
||||
}
|
||||
// PRAGMA does not accept a bind parameter.
|
||||
if _, err := tx.Exec(fmt.Sprintf(`PRAGMA user_version = %d`, i+1)); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("store: bump schema version to %d: %w", i+1, err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("store: commit migration %d: %w", i+1, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// write runs fn inside a write transaction, alone.
|
||||
func (s *Store) write(fn func(*sql.Tx) error) error {
|
||||
s.wmu.Lock()
|
||||
defer s.wmu.Unlock()
|
||||
return s.inTx(fn)
|
||||
}
|
||||
|
||||
// inTx assumes the caller already holds wmu.
|
||||
func (s *Store) inTx(fn func(*sql.Tx) error) error {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := fn(tx); err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// --- small helpers shared by the entity files -------------------------------
|
||||
|
||||
func now() time.Time { return time.Now().UTC().Truncate(time.Second) }
|
||||
|
||||
func unix(t time.Time) int64 { return t.UTC().Unix() }
|
||||
|
||||
func fromUnix(v int64) time.Time { return time.Unix(v, 0).UTC() }
|
||||
|
||||
func nullTime(v sql.NullInt64) *time.Time {
|
||||
if !v.Valid || v.Int64 == 0 {
|
||||
return nil
|
||||
}
|
||||
t := fromUnix(v.Int64)
|
||||
return &t
|
||||
}
|
||||
|
||||
func boolInt(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// newToken returns a 16-char URL-safe slug. 12 random bytes encode to exactly
|
||||
// 16 base64 characters with no padding — 96 bits of entropy, collisions are a
|
||||
// non-event, and the unique index catches them anyway.
|
||||
func newToken() string {
|
||||
b := make([]byte, 12)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic("store: crypto/rand failed: " + err.Error())
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
// newHWID returns a fresh 32-char lowercase hex HWID. internal/hwid exposes the
|
||||
// same thing publicly; the store cannot import it because hwid imports store.
|
||||
func newHWID() string {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic("store: crypto/rand failed: " + err.Error())
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// validToken keeps hand-picked tokens inside the URL-safe alphabet so that
|
||||
// /sub/{token} never needs escaping.
|
||||
func validToken(tok string) bool {
|
||||
if tok == "" || len(tok) > 64 {
|
||||
return false
|
||||
}
|
||||
for _, r := range tok {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
|
||||
case r == '-', r == '_':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isUniqueViolation(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := err.Error()
|
||||
return strings.Contains(msg, "UNIQUE constraint failed") ||
|
||||
strings.Contains(msg, "constraint failed: UNIQUE")
|
||||
}
|
||||
|
||||
// claimToken registers tok in the shared namespace. It returns ErrTokenTaken
|
||||
// when the slug already belongs to somebody else.
|
||||
func claimToken(tx *sql.Tx, tok, kind string, refID int64) error {
|
||||
_, err := tx.Exec(`INSERT INTO tokens (token, kind, ref_id) VALUES (?, ?, ?)`, tok, kind, refID)
|
||||
if isUniqueViolation(err) {
|
||||
return ErrTokenTaken
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// claimGeneratedToken picks a free slug, retrying on the (astronomically
|
||||
// unlikely) collision.
|
||||
func claimGeneratedToken(tx *sql.Tx, kind string, refID int64) (string, error) {
|
||||
for i := 0; i < 10; i++ {
|
||||
tok := newToken()
|
||||
err := claimToken(tx, tok, kind, refID)
|
||||
if err == nil {
|
||||
return tok, nil
|
||||
}
|
||||
if !errors.Is(err, ErrTokenTaken) {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return "", errors.New("store: could not find a free link token")
|
||||
}
|
||||
|
||||
func releaseToken(tx *sql.Tx, tok string) error {
|
||||
_, err := tx.Exec(`DELETE FROM tokens WHERE token = ?`, tok)
|
||||
return err
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n]
|
||||
}
|
||||
@@ -0,0 +1,713 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/omar/prizma/internal/model"
|
||||
)
|
||||
|
||||
func testStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
// A real file, not :memory: — WAL, the busy timeout and the migration path
|
||||
// only behave like production against a file.
|
||||
st, err := Open(filepath.Join(t.TempDir(), "prizma.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
return st
|
||||
}
|
||||
|
||||
func mustSource(t *testing.T, st *Store, name string) *model.Source {
|
||||
t.Helper()
|
||||
src := &model.Source{Name: name, URL: "https://panel.example/sub/abc", Enabled: true}
|
||||
if err := st.CreateSource(src); err != nil {
|
||||
t.Fatalf("CreateSource(%s): %v", name, err)
|
||||
}
|
||||
return src
|
||||
}
|
||||
|
||||
func TestOpenIsIdempotentAndMigrates(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "nested", "dir", "prizma.db")
|
||||
st, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
src := mustSource(t, st, "s1")
|
||||
if err := st.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
|
||||
st2, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("reopen: %v", err)
|
||||
}
|
||||
defer st2.Close()
|
||||
|
||||
got, err := st2.GetSource(src.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSource after reopen: %v", err)
|
||||
}
|
||||
if got.Name != "s1" || got.Token != src.Token {
|
||||
t.Fatalf("row did not survive reopen: %+v", got)
|
||||
}
|
||||
|
||||
var journal string
|
||||
if err := st2.DB().QueryRow(`PRAGMA journal_mode`).Scan(&journal); err != nil {
|
||||
t.Fatalf("journal_mode: %v", err)
|
||||
}
|
||||
if journal != "wal" {
|
||||
t.Fatalf("journal_mode = %q, want wal", journal)
|
||||
}
|
||||
var fk int
|
||||
if err := st2.DB().QueryRow(`PRAGMA foreign_keys`).Scan(&fk); err != nil {
|
||||
t.Fatalf("foreign_keys: %v", err)
|
||||
}
|
||||
if fk != 1 {
|
||||
t.Fatalf("foreign_keys = %d, want 1", fk)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceCRUD(t *testing.T) {
|
||||
st := testStore(t)
|
||||
|
||||
src := &model.Source{
|
||||
Name: "Remnawave",
|
||||
URL: "https://panel.example/sub/xyz",
|
||||
DeviceOS: "iOS",
|
||||
VerOS: "17.4",
|
||||
DeviceModel: "iPhone15,2",
|
||||
UserAgent: "Happ/1.0",
|
||||
ForwardClientUA: true,
|
||||
ExtraHeaders: map[string]string{"X-Tenant": "a"},
|
||||
CacheTTL: 60,
|
||||
Enabled: true,
|
||||
Note: "main seat",
|
||||
}
|
||||
if err := st.CreateSource(src); err != nil {
|
||||
t.Fatalf("CreateSource: %v", err)
|
||||
}
|
||||
if src.ID == 0 {
|
||||
t.Fatal("CreateSource did not fill ID")
|
||||
}
|
||||
if len(src.Token) != TokenLength {
|
||||
t.Fatalf("token %q has length %d, want %d", src.Token, len(src.Token), TokenLength)
|
||||
}
|
||||
if !regexp.MustCompile(`^[A-Za-z0-9_-]{16}$`).MatchString(src.Token) {
|
||||
t.Fatalf("token %q is not a url-safe slug", src.Token)
|
||||
}
|
||||
if !regexp.MustCompile(`^[0-9a-f]{32}$`).MatchString(src.HWID) {
|
||||
t.Fatalf("hwid %q is not 32 lowercase hex chars", src.HWID)
|
||||
}
|
||||
|
||||
got, err := st.GetSource(src.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSource: %v", err)
|
||||
}
|
||||
if got.ExtraHeaders["X-Tenant"] != "a" || !got.ForwardClientUA || got.CacheTTL != 60 ||
|
||||
got.DeviceModel != "iPhone15,2" || got.Note != "main seat" {
|
||||
t.Fatalf("round-trip lost fields: %+v", got)
|
||||
}
|
||||
|
||||
byTok, err := st.GetSourceByToken(src.Token)
|
||||
if err != nil || byTok.ID != src.ID {
|
||||
t.Fatalf("GetSourceByToken: %v %+v", err, byTok)
|
||||
}
|
||||
|
||||
// Update with an empty token/hwid must keep the published ones.
|
||||
got.Name = "Renamed"
|
||||
got.Token = ""
|
||||
got.HWID = ""
|
||||
if err := st.UpdateSource(got); err != nil {
|
||||
t.Fatalf("UpdateSource: %v", err)
|
||||
}
|
||||
if got.Token != src.Token || got.HWID != src.HWID {
|
||||
t.Fatalf("update clobbered token/hwid: %q %q", got.Token, got.HWID)
|
||||
}
|
||||
again, _ := st.GetSource(src.ID)
|
||||
if again.Name != "Renamed" {
|
||||
t.Fatalf("name not updated: %q", again.Name)
|
||||
}
|
||||
|
||||
// Rotating the HWID keeps the link stable.
|
||||
again.HWID = "0123456789abcdef0123456789abcdef"
|
||||
if err := st.UpdateSource(again); err != nil {
|
||||
t.Fatalf("UpdateSource rotate: %v", err)
|
||||
}
|
||||
if rotated, _ := st.GetSource(src.ID); rotated.HWID != "0123456789abcdef0123456789abcdef" || rotated.Token != src.Token {
|
||||
t.Fatalf("rotate: %+v", rotated)
|
||||
}
|
||||
|
||||
list, err := st.ListSources()
|
||||
if err != nil || len(list) != 1 {
|
||||
t.Fatalf("ListSources: %v %d", err, len(list))
|
||||
}
|
||||
|
||||
if err := st.DeleteSource(src.ID); err != nil {
|
||||
t.Fatalf("DeleteSource: %v", err)
|
||||
}
|
||||
if _, err := st.GetSource(src.ID); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("GetSource after delete: %v", err)
|
||||
}
|
||||
if _, err := st.GetSourceByToken(src.Token); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("token still resolves after delete: %v", err)
|
||||
}
|
||||
if err := st.DeleteSource(src.ID); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("second delete: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceValidation(t *testing.T) {
|
||||
st := testStore(t)
|
||||
if err := st.CreateSource(&model.Source{URL: "https://x"}); err == nil {
|
||||
t.Fatal("expected an error for a source without a name")
|
||||
}
|
||||
if err := st.CreateSource(&model.Source{Name: "x"}); err == nil {
|
||||
t.Fatal("expected an error for a source without a url")
|
||||
}
|
||||
if err := st.CreateSource(&model.Source{Name: "x", URL: "https://x", Token: "not a token!"}); err == nil {
|
||||
t.Fatal("expected an error for an invalid token")
|
||||
}
|
||||
if err := st.UpdateSource(&model.Source{ID: 9999, Name: "x", URL: "https://x"}); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("update of a missing source: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenNamespaceIsSharedBySourcesAndGroups(t *testing.T) {
|
||||
st := testStore(t)
|
||||
src := mustSource(t, st, "s1")
|
||||
|
||||
// A group may not steal a source's slug: /sub/{token} resolves either kind.
|
||||
g := &model.Group{Name: "g1", Token: src.Token, Enabled: true}
|
||||
if err := st.CreateGroup(g); !errors.Is(err, ErrTokenTaken) {
|
||||
t.Fatalf("group stole a source token: %v", err)
|
||||
}
|
||||
|
||||
if err := st.CreateGroup(&model.Group{Name: "g1", Enabled: true}); err != nil {
|
||||
t.Fatalf("CreateGroup: %v", err)
|
||||
}
|
||||
groups, _ := st.ListGroups()
|
||||
if len(groups) != 1 {
|
||||
t.Fatalf("want 1 group, got %d", len(groups))
|
||||
}
|
||||
gtok := groups[0].Token
|
||||
if gtok == src.Token {
|
||||
t.Fatal("generated tokens collided")
|
||||
}
|
||||
|
||||
// And the other way round.
|
||||
if err := st.CreateSource(&model.Source{Name: "s2", URL: "https://x", Token: gtok}); !errors.Is(err, ErrTokenTaken) {
|
||||
t.Fatalf("source stole a group token: %v", err)
|
||||
}
|
||||
// Renaming a source's token onto a group's is refused too.
|
||||
src.Token = gtok
|
||||
if err := st.UpdateSource(src); !errors.Is(err, ErrTokenTaken) {
|
||||
t.Fatalf("update stole a group token: %v", err)
|
||||
}
|
||||
|
||||
// A freed token can be reused.
|
||||
if err := st.DeleteGroup(groups[0].ID); err != nil {
|
||||
t.Fatalf("DeleteGroup: %v", err)
|
||||
}
|
||||
src.Token = gtok
|
||||
if err := st.UpdateSource(src); err != nil {
|
||||
t.Fatalf("reuse of a freed token: %v", err)
|
||||
}
|
||||
if got, err := st.GetSourceByToken(gtok); err != nil || got.ID != src.ID {
|
||||
t.Fatalf("GetSourceByToken after reuse: %v %+v", err, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupCRUDAndJSONColumns(t *testing.T) {
|
||||
st := testStore(t)
|
||||
s1 := mustSource(t, st, "alpha")
|
||||
s2 := mustSource(t, st, "beta")
|
||||
|
||||
g := &model.Group{
|
||||
Name: "merged",
|
||||
Members: []model.GroupMember{
|
||||
{SourceID: s1.ID, Prefix: "A", Filter: model.Filter{IncludeName: "(?i)nl", Limit: 5}},
|
||||
{SourceID: s2.ID},
|
||||
{SourceID: 0}, // must be dropped
|
||||
},
|
||||
Filter: model.Filter{ExcludeName: "trial", Protocols: []string{model.ProtoVLESS}, CaseSensitive: true},
|
||||
OutputFormat: "clash",
|
||||
NameTemplate: "{source} · {name}",
|
||||
Dedupe: true,
|
||||
Enabled: true,
|
||||
}
|
||||
if err := st.CreateGroup(g); err != nil {
|
||||
t.Fatalf("CreateGroup: %v", err)
|
||||
}
|
||||
if g.ID == 0 || len(g.Token) != TokenLength {
|
||||
t.Fatalf("CreateGroup did not fill ID/Token: %+v", g)
|
||||
}
|
||||
|
||||
got, err := st.GetGroupByToken(g.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("GetGroupByToken: %v", err)
|
||||
}
|
||||
if len(got.Members) != 2 {
|
||||
t.Fatalf("members = %d, want 2 (the zero source must be dropped)", len(got.Members))
|
||||
}
|
||||
if got.Members[0].Filter.IncludeName != "(?i)nl" || got.Members[0].Filter.Limit != 5 || got.Members[0].Prefix != "A" {
|
||||
t.Fatalf("member filter lost: %+v", got.Members[0])
|
||||
}
|
||||
if got.Members[0].Name != "alpha" || got.Members[1].Name != "beta" {
|
||||
t.Fatalf("member names not denormalized: %+v", got.Members)
|
||||
}
|
||||
if got.Filter.ExcludeName != "trial" || len(got.Filter.Protocols) != 1 || !got.Filter.CaseSensitive {
|
||||
t.Fatalf("group filter lost: %+v", got.Filter)
|
||||
}
|
||||
if !got.Dedupe || got.OutputFormat != "clash" || got.NameTemplate != "{source} · {name}" {
|
||||
t.Fatalf("group fields lost: %+v", got)
|
||||
}
|
||||
|
||||
// A renamed source shows up under its new name.
|
||||
s1.Name = "alpha-2"
|
||||
if err := st.UpdateSource(s1); err != nil {
|
||||
t.Fatalf("UpdateSource: %v", err)
|
||||
}
|
||||
if reread, _ := st.GetGroup(g.ID); reread.Members[0].Name != "alpha-2" {
|
||||
t.Fatalf("member name not refreshed: %q", reread.Members[0].Name)
|
||||
}
|
||||
|
||||
// Deleting a source prunes it from the group.
|
||||
if err := st.DeleteSource(s1.ID); err != nil {
|
||||
t.Fatalf("DeleteSource: %v", err)
|
||||
}
|
||||
pruned, err := st.GetGroup(g.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetGroup: %v", err)
|
||||
}
|
||||
if len(pruned.Members) != 1 || pruned.Members[0].SourceID != s2.ID {
|
||||
t.Fatalf("dead member not pruned: %+v", pruned.Members)
|
||||
}
|
||||
|
||||
pruned.Name = "merged-2"
|
||||
pruned.Enabled = false
|
||||
if err := st.UpdateGroup(pruned); err != nil {
|
||||
t.Fatalf("UpdateGroup: %v", err)
|
||||
}
|
||||
if reread, _ := st.GetGroup(g.ID); reread.Name != "merged-2" || reread.Enabled {
|
||||
t.Fatalf("update lost: %+v", reread)
|
||||
}
|
||||
|
||||
if err := st.DeleteGroup(g.ID); err != nil {
|
||||
t.Fatalf("DeleteGroup: %v", err)
|
||||
}
|
||||
if _, err := st.GetGroup(g.ID); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("GetGroup after delete: %v", err)
|
||||
}
|
||||
if _, err := st.GetGroupByToken(g.Token); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("token still resolves: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordSourceFetch(t *testing.T) {
|
||||
st := testStore(t)
|
||||
src := mustSource(t, st, "s")
|
||||
|
||||
if err := st.RecordSourceFetch(src.ID, true, 34, ""); err != nil {
|
||||
t.Fatalf("RecordSourceFetch ok: %v", err)
|
||||
}
|
||||
got, _ := st.GetSource(src.ID)
|
||||
if !got.LastFetchOK || got.LastNodeCount != 34 || got.LastFetchAt == nil || got.LastFetchError != "" {
|
||||
t.Fatalf("ok fetch not recorded: %+v", got)
|
||||
}
|
||||
|
||||
if err := st.RecordSourceFetch(src.ID, false, 0, "502 bad gateway"); err != nil {
|
||||
t.Fatalf("RecordSourceFetch fail: %v", err)
|
||||
}
|
||||
got, _ = st.GetSource(src.ID)
|
||||
if got.LastFetchOK || got.LastFetchError != "502 bad gateway" {
|
||||
t.Fatalf("failed fetch not recorded: %+v", got)
|
||||
}
|
||||
if got.LastNodeCount != 34 {
|
||||
t.Fatalf("a failed fetch dropped the last known node count: %d", got.LastNodeCount)
|
||||
}
|
||||
if err := st.RecordSourceFetch(4242, true, 1, ""); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("RecordSourceFetch on a missing source: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTouchClientUpsertsAndCounts(t *testing.T) {
|
||||
st := testStore(t)
|
||||
|
||||
c1, err := st.TouchClient(model.Client{
|
||||
HWID: "AABBCCDDEEFF00112233445566778899", UserAgent: "Happ/1.0",
|
||||
IP: "10.0.0.1", DeviceOS: "iOS", LastLink: "tok1", LastStatus: 200,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("TouchClient: %v", err)
|
||||
}
|
||||
if c1.HWID != "aabbccddeeff00112233445566778899" {
|
||||
t.Fatalf("hwid not normalized: %q", c1.HWID)
|
||||
}
|
||||
if c1.Hits != 1 || c1.ID == 0 || c1.FirstSeen.IsZero() {
|
||||
t.Fatalf("first touch: %+v", c1)
|
||||
}
|
||||
|
||||
// Ban it, then touch again: administrative state must survive, hits climb,
|
||||
// and fields the client stopped sending keep their last known value.
|
||||
if err := st.SetClientBanned(c1.ID, true); err != nil {
|
||||
t.Fatalf("SetClientBanned: %v", err)
|
||||
}
|
||||
if err := st.SetClientLabel(c1.ID, "omar's phone"); err != nil {
|
||||
t.Fatalf("SetClientLabel: %v", err)
|
||||
}
|
||||
c2, err := st.TouchClient(model.Client{HWID: "aabbccddeeff00112233445566778899", LastLink: "tok2"})
|
||||
if err != nil {
|
||||
t.Fatalf("TouchClient again: %v", err)
|
||||
}
|
||||
if c2.ID != c1.ID {
|
||||
t.Fatalf("upsert created a second row: %d vs %d", c2.ID, c1.ID)
|
||||
}
|
||||
if c2.Hits != 2 {
|
||||
t.Fatalf("hits = %d, want 2", c2.Hits)
|
||||
}
|
||||
if !c2.Banned || c2.Label != "omar's phone" {
|
||||
t.Fatalf("touch clobbered admin state: %+v", c2)
|
||||
}
|
||||
if c2.UserAgent != "Happ/1.0" || c2.IP != "10.0.0.1" || c2.LastStatus != 200 {
|
||||
t.Fatalf("touch clobbered last known values: %+v", c2)
|
||||
}
|
||||
if c2.LastLink != "tok2" {
|
||||
t.Fatalf("last link not updated: %q", c2.LastLink)
|
||||
}
|
||||
if !c2.FirstSeen.Equal(c1.FirstSeen) {
|
||||
t.Fatalf("first_seen moved: %v -> %v", c1.FirstSeen, c2.FirstSeen)
|
||||
}
|
||||
|
||||
if _, err := st.TouchClient(model.Client{HWID: " "}); err == nil {
|
||||
t.Fatal("expected an error for an empty hwid")
|
||||
}
|
||||
|
||||
if _, err := st.LookupClient("nope"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("LookupClient(missing): %v", err)
|
||||
}
|
||||
found, err := st.LookupClient("AABBCCDDEEFF00112233445566778899")
|
||||
if err != nil || found.ID != c1.ID {
|
||||
t.Fatalf("LookupClient is case sensitive: %v %+v", err, found)
|
||||
}
|
||||
|
||||
if err := st.DeleteClient(c1.ID); err != nil {
|
||||
t.Fatalf("DeleteClient: %v", err)
|
||||
}
|
||||
if err := st.DeleteClient(c1.ID); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("second delete: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListClientsSearchFilterSortPage(t *testing.T) {
|
||||
st := testStore(t)
|
||||
|
||||
seed := []model.Client{
|
||||
{HWID: "1111111111111111111111111111111a", UserAgent: "Happ/1.0", IP: "10.0.0.1"},
|
||||
{HWID: "2222222222222222222222222222222b", UserAgent: "Clash/2", IP: "10.0.0.2"},
|
||||
{HWID: "3333333333333333333333333333333c", UserAgent: "v2rayN/6", IP: "192.168.5.7"},
|
||||
}
|
||||
var ids []int64
|
||||
for _, c := range seed {
|
||||
got, err := st.TouchClient(c)
|
||||
if err != nil {
|
||||
t.Fatalf("TouchClient: %v", err)
|
||||
}
|
||||
ids = append(ids, got.ID)
|
||||
}
|
||||
// Give the third client the most hits.
|
||||
for i := 0; i < 3; i++ {
|
||||
if _, err := st.TouchClient(seed[2]); err != nil {
|
||||
t.Fatalf("TouchClient: %v", err)
|
||||
}
|
||||
}
|
||||
// Timestamps are second-granular, so spread them by hand instead of
|
||||
// sleeping: client[0] oldest, client[2] newest.
|
||||
for i, id := range ids {
|
||||
ts := unix(now().Add(time.Duration(i-len(ids)) * time.Hour))
|
||||
if _, err := st.DB().Exec(`UPDATE clients SET first_seen = ?, last_seen = ? WHERE id = ?`, ts, ts, id); err != nil {
|
||||
t.Fatalf("spread timestamps: %v", err)
|
||||
}
|
||||
}
|
||||
if err := st.SetClientLabel(ids[0], "kitchen tv"); err != nil {
|
||||
t.Fatalf("SetClientLabel: %v", err)
|
||||
}
|
||||
if err := st.SetClientBanned(ids[1], true); err != nil {
|
||||
t.Fatalf("SetClientBanned: %v", err)
|
||||
}
|
||||
if err := st.SetClientAllowed(ids[2], true); err != nil {
|
||||
t.Fatalf("SetClientAllowed: %v", err)
|
||||
}
|
||||
|
||||
all, total, err := st.ListClients(ClientQuery{})
|
||||
if err != nil {
|
||||
t.Fatalf("ListClients: %v", err)
|
||||
}
|
||||
if total != 3 || len(all) != 3 {
|
||||
t.Fatalf("total=%d rows=%d, want 3/3", total, len(all))
|
||||
}
|
||||
if all[0].ID != ids[2] {
|
||||
t.Fatalf("default sort is not last_seen desc: %+v", all[0])
|
||||
}
|
||||
|
||||
byHits, _, err := st.ListClients(ClientQuery{Sort: "hits"})
|
||||
if err != nil || byHits[0].ID != ids[2] || byHits[0].Hits != 4 {
|
||||
t.Fatalf("sort by hits: %v %+v", err, byHits[0])
|
||||
}
|
||||
byFirst, _, _ := st.ListClients(ClientQuery{Sort: "first_seen"})
|
||||
if byFirst[0].ID != ids[2] || byFirst[2].ID != ids[0] {
|
||||
t.Fatalf("sort by first_seen desc: %+v", byFirst)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
search string
|
||||
want int64
|
||||
}{
|
||||
{"kitchen", ids[0]}, // label
|
||||
{"CLASH", ids[1]}, // ua, case-insensitive
|
||||
{"192.168", ids[2]}, // ip
|
||||
{"2222222", ids[1]}, // hwid
|
||||
} {
|
||||
rows, n, err := st.ListClients(ClientQuery{Search: tc.search})
|
||||
if err != nil {
|
||||
t.Fatalf("search %q: %v", tc.search, err)
|
||||
}
|
||||
if n != 1 || len(rows) != 1 || rows[0].ID != tc.want {
|
||||
t.Fatalf("search %q matched %d rows %+v", tc.search, n, rows)
|
||||
}
|
||||
}
|
||||
if _, n, _ := st.ListClients(ClientQuery{Search: "%"}); n != 0 {
|
||||
t.Fatalf("LIKE wildcards must be escaped, matched %d rows", n)
|
||||
}
|
||||
|
||||
yes, no := true, false
|
||||
if rows, n, _ := st.ListClients(ClientQuery{Banned: &yes}); n != 1 || rows[0].ID != ids[1] {
|
||||
t.Fatalf("banned filter: %d %+v", n, rows)
|
||||
}
|
||||
if _, n, _ := st.ListClients(ClientQuery{Banned: &no}); n != 2 {
|
||||
t.Fatalf("not-banned filter: %d", n)
|
||||
}
|
||||
if rows, n, _ := st.ListClients(ClientQuery{Allowed: &yes}); n != 1 || rows[0].ID != ids[2] {
|
||||
t.Fatalf("allowed filter: %d %+v", n, rows)
|
||||
}
|
||||
if rows, n, _ := st.ListClients(ClientQuery{Allowed: &yes, Banned: &yes}); n != 0 || len(rows) != 0 {
|
||||
t.Fatalf("combined filters: %d %+v", n, rows)
|
||||
}
|
||||
|
||||
page1, total, _ := st.ListClients(ClientQuery{Limit: 2, Offset: 0})
|
||||
page2, _, _ := st.ListClients(ClientQuery{Limit: 2, Offset: 2})
|
||||
if total != 3 || len(page1) != 2 || len(page2) != 1 {
|
||||
t.Fatalf("paging: total=%d p1=%d p2=%d", total, len(page1), len(page2))
|
||||
}
|
||||
if page1[0].ID == page2[0].ID {
|
||||
t.Fatal("paging returned the same row twice")
|
||||
}
|
||||
|
||||
n, err := st.WhitelistCount()
|
||||
if err != nil || n != 1 {
|
||||
t.Fatalf("WhitelistCount = %d, %v", n, err)
|
||||
}
|
||||
if err := st.SetClientAllowed(ids[2], false); err != nil {
|
||||
t.Fatalf("SetClientAllowed(false): %v", err)
|
||||
}
|
||||
if n, _ := st.WhitelistCount(); n != 0 {
|
||||
t.Fatalf("WhitelistCount after removal = %d", n)
|
||||
}
|
||||
if err := st.SetClientAllowed(999, true); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("SetClientAllowed on a missing client: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettings(t *testing.T) {
|
||||
st := testStore(t)
|
||||
|
||||
v, err := st.Setting("jwt_secret")
|
||||
if err != nil || v != "" {
|
||||
t.Fatalf("missing setting = %q, %v; want empty and no error", v, err)
|
||||
}
|
||||
if err := st.SetSetting("jwt_secret", "s3cret"); err != nil {
|
||||
t.Fatalf("SetSetting: %v", err)
|
||||
}
|
||||
if v, _ := st.Setting("jwt_secret"); v != "s3cret" {
|
||||
t.Fatalf("Setting = %q", v)
|
||||
}
|
||||
if err := st.SetSetting("jwt_secret", "rotated"); err != nil {
|
||||
t.Fatalf("SetSetting overwrite: %v", err)
|
||||
}
|
||||
if v, _ := st.Setting("jwt_secret"); v != "rotated" {
|
||||
t.Fatalf("overwrite failed: %q", v)
|
||||
}
|
||||
if err := st.SetSetting(" ", "x"); err == nil {
|
||||
t.Fatal("expected an error for an empty key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogRequestStatsAndHits(t *testing.T) {
|
||||
st := testStore(t)
|
||||
src := mustSource(t, st, "s")
|
||||
g := &model.Group{Name: "g", Enabled: true}
|
||||
if err := st.CreateGroup(g); err != nil {
|
||||
t.Fatalf("CreateGroup: %v", err)
|
||||
}
|
||||
if _, err := st.TouchClient(model.Client{HWID: "aa11bb22cc33dd44ee55ff6677889900"}); err != nil {
|
||||
t.Fatalf("TouchClient: %v", err)
|
||||
}
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := st.LogRequest("/sub/"+src.Token, "aa11bb22cc33dd44ee55ff6677889900", 200); err != nil {
|
||||
t.Fatalf("LogRequest: %v", err)
|
||||
}
|
||||
}
|
||||
if err := st.LogRequest(g.Token, "aa11bb22cc33dd44ee55ff6677889900", 200); err != nil {
|
||||
t.Fatalf("LogRequest bare token: %v", err)
|
||||
}
|
||||
if err := st.LogRequest("group:"+itoa(g.ID), "", 403); err != nil {
|
||||
t.Fatalf("LogRequest kind:id: %v", err)
|
||||
}
|
||||
if err := st.LogRequest("", "", 404); err != nil {
|
||||
t.Fatalf("LogRequest with no link: %v", err)
|
||||
}
|
||||
|
||||
gotSrc, _ := st.GetSource(src.ID)
|
||||
if gotSrc.Hits != 3 {
|
||||
t.Fatalf("source hits = %d, want 3", gotSrc.Hits)
|
||||
}
|
||||
gotGrp, _ := st.GetGroup(g.ID)
|
||||
if gotGrp.Hits != 2 {
|
||||
t.Fatalf("group hits = %d, want 2", gotGrp.Hits)
|
||||
}
|
||||
|
||||
stats, err := st.Stats()
|
||||
if err != nil {
|
||||
t.Fatalf("Stats: %v", err)
|
||||
}
|
||||
if stats.Sources != 1 || stats.Groups != 1 || stats.Clients != 1 {
|
||||
t.Fatalf("counts: %+v", stats)
|
||||
}
|
||||
if stats.Requests24h != 6 || stats.TotalRequests != 6 {
|
||||
t.Fatalf("request counters: %+v", stats)
|
||||
}
|
||||
|
||||
// A request older than 24h counts towards the total but not the window,
|
||||
// and one older than the retention window is pruned on the next write.
|
||||
old := unix(now().Add(-40 * time.Hour))
|
||||
ancient := unix(now().Add(-45 * 24 * time.Hour))
|
||||
if _, err := st.DB().Exec(`INSERT INTO requests (ts, link, hwid, status) VALUES (?,'x','',200),(?,'x','',200)`, old, ancient); err != nil {
|
||||
t.Fatalf("seed old rows: %v", err)
|
||||
}
|
||||
stats, _ = st.Stats()
|
||||
if stats.Requests24h != 6 {
|
||||
t.Fatalf("old rows leaked into the 24h window: %+v", stats)
|
||||
}
|
||||
|
||||
st.logN.Store(0) // force the retention sweep on the next LogRequest
|
||||
if err := st.LogRequest("", "", 200); err != nil {
|
||||
t.Fatalf("LogRequest: %v", err)
|
||||
}
|
||||
var remaining int
|
||||
if err := st.DB().QueryRow(`SELECT COUNT(*) FROM requests WHERE ts < ?`, unix(now().Add(-requestRetention))).Scan(&remaining); err != nil {
|
||||
t.Fatalf("count old rows: %v", err)
|
||||
}
|
||||
if remaining != 0 {
|
||||
t.Fatalf("%d rows older than the retention window survived", remaining)
|
||||
}
|
||||
stats, _ = st.Stats()
|
||||
if stats.TotalRequests != 7 {
|
||||
t.Fatalf("pruning shrank the lifetime total: %+v", stats)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLinkRef(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
kind string
|
||||
id int64
|
||||
token string
|
||||
}{
|
||||
{"/sub/abc123", "", 0, "abc123"},
|
||||
{"/sub/abc123/clash.yaml", "", 0, "abc123"},
|
||||
{"/sub/abc123?format=clash", "", 0, "abc123"},
|
||||
{"abc123", "", 0, "abc123"},
|
||||
{"source:12", "source", 12, ""},
|
||||
{"group:7", "group", 7, ""},
|
||||
{"", "", 0, ""},
|
||||
{"/sub/", "", 0, ""},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
k, id, tok := parseLinkRef(tc.in)
|
||||
if k != tc.kind || id != tc.id || tok != tc.token {
|
||||
t.Errorf("parseLinkRef(%q) = %q,%d,%q; want %q,%d,%q", tc.in, k, id, tok, tc.kind, tc.id, tc.token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentWrites(t *testing.T) {
|
||||
st := testStore(t)
|
||||
src := mustSource(t, st, "s")
|
||||
|
||||
const workers = 8
|
||||
const each = 10
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, workers*each)
|
||||
for w := 0; w < workers; w++ {
|
||||
wg.Add(1)
|
||||
go func(w int) {
|
||||
defer wg.Done()
|
||||
for i := 0; i < each; i++ {
|
||||
if _, err := st.TouchClient(model.Client{
|
||||
HWID: hwidFor(w), UserAgent: "ua", IP: "1.2.3.4", LastLink: src.Token,
|
||||
}); err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
if err := st.LogRequest("/sub/"+src.Token, hwidFor(w), 200); err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
}(w)
|
||||
}
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
t.Fatalf("concurrent write: %v", err)
|
||||
}
|
||||
|
||||
rows, total, err := st.ListClients(ClientQuery{Limit: 100})
|
||||
if err != nil {
|
||||
t.Fatalf("ListClients: %v", err)
|
||||
}
|
||||
if total != workers {
|
||||
t.Fatalf("clients = %d, want %d", total, workers)
|
||||
}
|
||||
for _, c := range rows {
|
||||
if c.Hits != each {
|
||||
t.Fatalf("client %s hits = %d, want %d", c.HWID, c.Hits, each)
|
||||
}
|
||||
}
|
||||
got, _ := st.GetSource(src.ID)
|
||||
if got.Hits != workers*each {
|
||||
t.Fatalf("source hits = %d, want %d", got.Hits, workers*each)
|
||||
}
|
||||
}
|
||||
|
||||
func hwidFor(w int) string {
|
||||
return "cafebabe0000000000000000000000" + string(rune('0'+w/10)) + string(rune('0'+w%10))
|
||||
}
|
||||
|
||||
func itoa(v int64) string {
|
||||
if v == 0 {
|
||||
return "0"
|
||||
}
|
||||
var b []byte
|
||||
for v > 0 {
|
||||
b = append([]byte{byte('0' + v%10)}, b...)
|
||||
v /= 10
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package subfmt
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// detectMaxDepth bounds the base64-unwrapping recursion. Two layers is already
|
||||
// pathological; the limit exists so a body of "AAAA…" that keeps decoding to
|
||||
// more base64 cannot spin.
|
||||
const detectMaxDepth = 3
|
||||
|
||||
// Detect sniffs the format from the body and the upstream Content-Type.
|
||||
//
|
||||
// A base64 wrapper is peeled off before the decision is made, so base64 of a
|
||||
// Clash YAML reports clash — only base64 of a URI list reports base64.
|
||||
func Detect(body []byte, contentType string) Format {
|
||||
f, _ := parseDetect(body, contentType, 0)
|
||||
return f
|
||||
}
|
||||
|
||||
// parseDetect returns the format together with the payload that actually
|
||||
// carries it: identical to the input except when a base64 wrapper was peeled.
|
||||
func parseDetect(body []byte, contentType string, depth int) (Format, []byte) {
|
||||
payload := bytes.TrimSpace(parseTrimBOM(body))
|
||||
if len(payload) == 0 {
|
||||
return FormatUnknown, payload
|
||||
}
|
||||
ct := strings.ToLower(contentType)
|
||||
|
||||
if payload[0] == '[' || payload[0] == '{' {
|
||||
if f := parseDetectJSON(payload); f != FormatUnknown {
|
||||
return f, payload
|
||||
}
|
||||
}
|
||||
|
||||
// Textual shapes are checked before base64 because neither YAML nor a URI
|
||||
// list can be mistaken for base64 (both contain ':'), while the reverse
|
||||
// check is cheap and unambiguous.
|
||||
if f := parseDetectText(payload, ct); f != FormatUnknown {
|
||||
return f, payload
|
||||
}
|
||||
|
||||
if depth < detectMaxDepth && b64IsLikely(payload) {
|
||||
if dec, ok := b64Decode(string(payload)); ok {
|
||||
inner, innerPayload := parseDetect(dec, "", depth+1)
|
||||
switch inner {
|
||||
case FormatUnknown:
|
||||
// Decoded to noise: keep treating the original as opaque.
|
||||
case FormatURIList:
|
||||
return FormatBase64, innerPayload
|
||||
default:
|
||||
return inner, innerPayload
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.Contains(ct, "yaml"), strings.Contains(ct, "yml"):
|
||||
return FormatClash, payload
|
||||
}
|
||||
return FormatUnknown, payload
|
||||
}
|
||||
|
||||
// parseDetectJSON distinguishes the three JSON dialects. Happ ships a bare
|
||||
// array of whole Xray configs; sing-box tags outbounds with "type" and its
|
||||
// routing block is "route"; Xray uses "protocol" and "routing".
|
||||
func parseDetectJSON(b []byte) Format {
|
||||
if b[0] == '[' {
|
||||
var arr []json.RawMessage
|
||||
if err := json.Unmarshal(b, &arr); err != nil {
|
||||
return FormatUnknown
|
||||
}
|
||||
return FormatXray
|
||||
}
|
||||
var root map[string]json.RawMessage
|
||||
if err := json.Unmarshal(b, &root); err != nil {
|
||||
return FormatUnknown
|
||||
}
|
||||
if _, ok := root["proxies"]; ok {
|
||||
// JSON is valid YAML, so a JSON-serialized Clash profile parses with
|
||||
// the Clash reader unchanged.
|
||||
return FormatClash
|
||||
}
|
||||
if _, ok := root["route"]; ok {
|
||||
return FormatSingBox
|
||||
}
|
||||
if _, ok := root["routing"]; ok {
|
||||
return FormatXray
|
||||
}
|
||||
if ob, ok := root["outbounds"]; ok {
|
||||
var outs []map[string]json.RawMessage
|
||||
if json.Unmarshal(ob, &outs) == nil {
|
||||
for _, o := range outs {
|
||||
if _, ok := o["protocol"]; ok {
|
||||
return FormatXray
|
||||
}
|
||||
if _, ok := o["type"]; ok {
|
||||
return FormatSingBox
|
||||
}
|
||||
}
|
||||
}
|
||||
return FormatSingBox
|
||||
}
|
||||
if _, ok := root["endpoints"]; ok {
|
||||
return FormatSingBox
|
||||
}
|
||||
if _, ok := root["inbounds"]; ok {
|
||||
return FormatXray
|
||||
}
|
||||
return FormatUnknown
|
||||
}
|
||||
|
||||
// clashMarkers are top-level keys that only a Clash-family profile carries.
|
||||
var clashMarkers = []string{"proxies:", "proxy-groups:", "proxy-providers:"}
|
||||
|
||||
func parseDetectText(b []byte, ct string) Format {
|
||||
if parseLooksClash(b) {
|
||||
return FormatClash
|
||||
}
|
||||
if parseCountURILines(b) > 0 {
|
||||
return FormatURIList
|
||||
}
|
||||
if strings.Contains(ct, "yaml") || strings.Contains(ct, "yml") {
|
||||
return FormatClash
|
||||
}
|
||||
return FormatUnknown
|
||||
}
|
||||
|
||||
// parseLooksClash searches for a Clash-only key at column zero. Indented hits
|
||||
// are ignored so a nested "proxies:" inside some other document cannot
|
||||
// hijack the detection.
|
||||
func parseLooksClash(b []byte) bool {
|
||||
for _, line := range bytes.Split(b, []byte("\n")) {
|
||||
if len(line) == 0 || line[0] == ' ' || line[0] == '\t' || line[0] == '#' || line[0] == '-' {
|
||||
continue
|
||||
}
|
||||
s := strings.TrimRight(string(line), " \t\r")
|
||||
for _, marker := range clashMarkers {
|
||||
if s == marker || strings.HasPrefix(s, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// parseCountURILines counts lines that open with something shaped like
|
||||
// "scheme://". It does not validate the scheme: an unknown one still round
|
||||
// trips through RawURI, and demanding a known scheme would make an all-exotic
|
||||
// subscription undetectable.
|
||||
func parseCountURILines(b []byte) int {
|
||||
n := 0
|
||||
for _, line := range parseSplitLines(b) {
|
||||
if parseHasURIScheme(line) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func parseHasURIScheme(line string) bool {
|
||||
i := strings.Index(line, "://")
|
||||
if i <= 0 {
|
||||
return false
|
||||
}
|
||||
for j := 0; j < i; j++ {
|
||||
c := line[j]
|
||||
switch {
|
||||
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9':
|
||||
case c == '+', c == '-', c == '.':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
c := line[0]
|
||||
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
// Package subfmt parses and renders every subscription format Prizma speaks:
|
||||
// plain URI lists, the base64 blob v2rayN popularized, Clash/Mihomo YAML,
|
||||
// sing-box JSON and Xray JSON (including the Happ flavour, an array of whole
|
||||
// configs where each element is one server).
|
||||
//
|
||||
// Parsing is deliberately forgiving. Upstream panels emit truncated base64,
|
||||
// URL-safe alphabets, mixed line endings and the occasional broken line; none
|
||||
// of that may cost the user a whole subscription, so a malformed entry is
|
||||
// skipped and the rest is kept.
|
||||
package subfmt
|
||||
|
||||
import "strings"
|
||||
|
||||
// Format identifies a subscription serialization.
|
||||
type Format string
|
||||
|
||||
const (
|
||||
FormatURIList Format = "uri" // plain, one proxy URI per line
|
||||
FormatBase64 Format = "base64" // base64(URI list) — the v2rayN classic
|
||||
FormatClash Format = "clash" // Clash / Clash.Meta / Mihomo YAML
|
||||
FormatSingBox Format = "singbox" // sing-box JSON {"outbounds":[...]}
|
||||
FormatXray Format = "xray" // Xray JSON config, or a Happ-style ARRAY of configs
|
||||
FormatUnknown Format = "unknown"
|
||||
)
|
||||
|
||||
func (f Format) String() string { return string(f) }
|
||||
|
||||
// AllFormats lists the formats a client may ask for, in UI order.
|
||||
func AllFormats() []Format {
|
||||
return []Format{FormatBase64, FormatURIList, FormatClash, FormatSingBox, FormatXray}
|
||||
}
|
||||
|
||||
// ContentTypeFor is the Content-Type header a rendered body of format f needs.
|
||||
func ContentTypeFor(f Format) string {
|
||||
switch f {
|
||||
case FormatClash:
|
||||
return "text/yaml; charset=utf-8"
|
||||
case FormatSingBox, FormatXray:
|
||||
return "application/json; charset=utf-8"
|
||||
default:
|
||||
// URI lists and base64 blobs must stay text/plain: clients sniff the
|
||||
// body themselves and some refuse anything else.
|
||||
return "text/plain; charset=utf-8"
|
||||
}
|
||||
}
|
||||
|
||||
// uaFormats maps a User-Agent substring to the format that client understands.
|
||||
// Order matters — the first hit wins, so more specific tokens come first.
|
||||
var uaFormats = []struct {
|
||||
needle string
|
||||
format Format
|
||||
}{
|
||||
// sing-box family. Hiddify and Karing ship sing-box cores.
|
||||
{"sing-box", FormatSingBox},
|
||||
{"sing_box", FormatSingBox},
|
||||
{"singbox", FormatSingBox},
|
||||
{"hiddify", FormatSingBox},
|
||||
{"karing", FormatSingBox},
|
||||
|
||||
// Clash family. "clash" also covers clash-verge, FlClash, ClashX, mihomo
|
||||
// builds that still call themselves clash.meta.
|
||||
{"clash", FormatClash},
|
||||
{"mihomo", FormatClash},
|
||||
{"stash", FormatClash},
|
||||
|
||||
// Everyone else eats the base64 URI blob.
|
||||
{"happ", FormatBase64},
|
||||
{"v2rayn", FormatBase64},
|
||||
{"v2rayng", FormatBase64},
|
||||
{"nekobox", FormatBase64},
|
||||
{"nekoray", FormatBase64},
|
||||
{"streisand", FormatBase64},
|
||||
{"shadowrocket", FormatBase64},
|
||||
{"v2box", FormatBase64},
|
||||
{"foxray", FormatBase64},
|
||||
{"loon", FormatBase64},
|
||||
{"surge", FormatBase64},
|
||||
{"quantumult", FormatBase64},
|
||||
{"v2ray", FormatBase64},
|
||||
}
|
||||
|
||||
// FormatForUA negotiates an output format from a client User-Agent.
|
||||
// An unrecognized (or absent) agent gets base64, the closest thing to a
|
||||
// universal format.
|
||||
func FormatForUA(ua string) Format {
|
||||
l := strings.ToLower(ua)
|
||||
for _, m := range uaFormats {
|
||||
if strings.Contains(l, m.needle) {
|
||||
return m.format
|
||||
}
|
||||
}
|
||||
return FormatBase64
|
||||
}
|
||||
|
||||
// formatAliases maps every ?format= spelling worth honouring onto a Format.
|
||||
var formatAliases = map[string]Format{
|
||||
"uri": FormatURIList,
|
||||
"uris": FormatURIList,
|
||||
"url": FormatURIList,
|
||||
"urls": FormatURIList,
|
||||
"link": FormatURIList,
|
||||
"links": FormatURIList,
|
||||
"list": FormatURIList,
|
||||
"plain": FormatURIList,
|
||||
"text": FormatURIList,
|
||||
"txt": FormatURIList,
|
||||
"raw": FormatURIList,
|
||||
"base64": FormatBase64,
|
||||
"b64": FormatBase64,
|
||||
"v2ray": FormatBase64,
|
||||
"v2rayn": FormatBase64,
|
||||
"v2rayng": FormatBase64,
|
||||
"sub": FormatBase64,
|
||||
"clash": FormatClash,
|
||||
"clashmeta": FormatClash,
|
||||
"clash-meta": FormatClash,
|
||||
"clash.meta": FormatClash,
|
||||
"mihomo": FormatClash,
|
||||
"stash": FormatClash,
|
||||
"yaml": FormatClash,
|
||||
"yml": FormatClash,
|
||||
"singbox": FormatSingBox,
|
||||
"sing-box": FormatSingBox,
|
||||
"sing_box": FormatSingBox,
|
||||
"sb": FormatSingBox,
|
||||
"hiddify": FormatSingBox,
|
||||
"xray": FormatXray,
|
||||
"xray-json": FormatXray,
|
||||
"v2ray-json": FormatXray,
|
||||
"json": FormatXray,
|
||||
"happ": FormatXray,
|
||||
"happ-json": FormatXray,
|
||||
"outbound": FormatXray,
|
||||
"outbounds": FormatXray,
|
||||
}
|
||||
|
||||
// ParseFormat maps a ?format= query value (and its common aliases) to a Format.
|
||||
// The bool is false for anything unrecognized, including the empty string, so
|
||||
// callers can tell "not asked" from "asked for something we do not have".
|
||||
func ParseFormat(s string) (Format, bool) {
|
||||
key := strings.ToLower(strings.TrimSpace(s))
|
||||
if key == "" {
|
||||
return FormatUnknown, false
|
||||
}
|
||||
f, ok := formatAliases[key]
|
||||
if !ok {
|
||||
return FormatUnknown, false
|
||||
}
|
||||
return f, true
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package subfmt
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/omar/prizma/internal/model"
|
||||
)
|
||||
|
||||
// ErrUnknownFormat is returned when a non-empty body matches no known
|
||||
// subscription serialization.
|
||||
var ErrUnknownFormat = errors.New("subfmt: unrecognized subscription format")
|
||||
|
||||
// Parse decodes a subscription body into nodes, returning the format it used.
|
||||
//
|
||||
// An empty body is not an error: an upstream with no servers left legitimately
|
||||
// answers with nothing. A single malformed entry is never fatal either — it is
|
||||
// skipped so the remaining servers still reach the client.
|
||||
func Parse(body []byte, contentType string) ([]model.Node, Format, error) {
|
||||
format, payload := parseDetect(body, contentType, 0)
|
||||
if len(payload) == 0 {
|
||||
return nil, format, nil
|
||||
}
|
||||
|
||||
switch format {
|
||||
case FormatURIList, FormatBase64:
|
||||
return parseURIList(payload), format, nil
|
||||
|
||||
case FormatClash:
|
||||
nodes, err := parseClashDoc(payload)
|
||||
if err != nil {
|
||||
return nil, format, err
|
||||
}
|
||||
return nodes, format, nil
|
||||
|
||||
case FormatSingBox:
|
||||
nodes, err := parseSingBoxDoc(payload)
|
||||
if err != nil {
|
||||
return nil, format, err
|
||||
}
|
||||
return nodes, format, nil
|
||||
|
||||
case FormatXray:
|
||||
nodes, err := parseXrayDoc(payload)
|
||||
if err != nil {
|
||||
return nil, format, err
|
||||
}
|
||||
return nodes, format, nil
|
||||
}
|
||||
|
||||
// Detection failed but the body may still be a URI list of schemes odd
|
||||
// enough that the sniffer refused to commit. Trying costs nothing.
|
||||
if nodes := parseURIList(payload); len(nodes) > 0 {
|
||||
return nodes, FormatURIList, nil
|
||||
}
|
||||
return nil, FormatUnknown, fmt.Errorf("%w (%d bytes)", ErrUnknownFormat, len(payload))
|
||||
}
|
||||
|
||||
// parseURIList turns a newline-separated list of proxy URIs into nodes,
|
||||
// skipping anything that will not parse.
|
||||
func parseURIList(body []byte) []model.Node {
|
||||
lines := parseSplitLines(body)
|
||||
out := make([]model.Node, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
n, err := ParseURI(line)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, n)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
package subfmt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/omar/prizma/internal/model"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// parseClashDoc reads a Clash / Clash.Meta / Mihomo profile.
|
||||
//
|
||||
// The whole root document is kept on every node as RawDoc so a Clash renderer
|
||||
// can rebuild the profile around the surviving proxies with the upstream's own
|
||||
// dns/rules/proxy-groups blocks intact, and each proxy mapping is kept as
|
||||
// RawClash so vendor-specific keys Prizma never normalized are not lost.
|
||||
func parseClashDoc(body []byte) ([]model.Node, error) {
|
||||
var doc map[string]any
|
||||
if err := yaml.Unmarshal(body, &doc); err != nil {
|
||||
return nil, fmt.Errorf("subfmt: clash yaml: %w", err)
|
||||
}
|
||||
if doc == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
proxies := parseAnySlice(doc["proxies"])
|
||||
out := make([]model.Node, 0, len(proxies))
|
||||
for _, item := range proxies {
|
||||
p := parseAnyMap(item)
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
n, ok := parseClashProxy(p, doc)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, n)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func parseClashProxy(p map[string]any, doc map[string]any) (model.Node, bool) {
|
||||
server := parseGetString(p, "server")
|
||||
name := parseCleanName(parseGetString(p, "name"))
|
||||
if server == "" && name == "" {
|
||||
return model.Node{}, false
|
||||
}
|
||||
|
||||
n := model.Node{
|
||||
Name: name,
|
||||
Protocol: parseNormProtocol(parseGetString(p, "type")),
|
||||
Server: server,
|
||||
Port: parseGetInt(p, "port"),
|
||||
RawClash: p,
|
||||
RawDoc: doc,
|
||||
}
|
||||
if n.Port == 0 {
|
||||
// Mihomo accepts a port range for some protocols; the first port is the
|
||||
// only one a normalized Node can carry.
|
||||
if pr := parseGetString(p, "ports", "port-range"); pr != "" {
|
||||
n.Port = parseLeadingInt(pr)
|
||||
}
|
||||
}
|
||||
|
||||
n.UUID = parseGetString(p, "uuid")
|
||||
n.Password = parseGetString(p, "password")
|
||||
n.Method = parseGetString(p, "cipher", "method", "encrypt-method")
|
||||
n.AlterID = parseGetInt(p, "alterId", "alterid", "alter-id")
|
||||
n.Flow = parseGetString(p, "flow")
|
||||
if v := parseGetString(p, "network"); v != "" {
|
||||
n.Network = parseNormNetwork(v)
|
||||
}
|
||||
n.SNI = parseGetString(p, "servername", "sni", "peer")
|
||||
n.Fingerprint = parseGetString(p, "client-fingerprint", "fingerprint")
|
||||
n.ALPN = parseAnyStringList(parseGet(p, "alpn"))
|
||||
n.AllowInsecure = parseGetBool(p, "skip-cert-verify", "allow-insecure", "insecure")
|
||||
n.Obfs = parseGetString(p, "obfs")
|
||||
n.ObfsPassword = parseGetString(p, "obfs-password", "obfs-param")
|
||||
n.UpMbps = parseAnyInt(parseGet(p, "up", "up-mbps", "up_mbps"))
|
||||
n.DownMbps = parseAnyInt(parseGet(p, "down", "down-mbps", "down_mbps"))
|
||||
n.Congestion = parseGetString(p, "congestion-controller", "congestion_control", "congestion")
|
||||
|
||||
if parseGetBool(p, "tls") {
|
||||
n.Security = "tls"
|
||||
}
|
||||
if reality := parseAnyMap(parseGet(p, "reality-opts")); reality != nil {
|
||||
n.Security = "reality"
|
||||
n.PublicKey = parseGetString(reality, "public-key", "publicKey")
|
||||
n.ShortID = parseGetString(reality, "short-id", "shortId")
|
||||
if v := parseGetString(reality, "support-x25519mlkem768"); v != "" {
|
||||
parseSetExtra(&n, "support-x25519mlkem768", v)
|
||||
}
|
||||
}
|
||||
|
||||
parseClashTransport(&n, p)
|
||||
parseClashPerProtocol(&n, p)
|
||||
|
||||
// Clash omits "tls: true" for the protocols where TLS is not optional, but
|
||||
// the other formats state it explicitly; normalize so a cross-format
|
||||
// renderer does not emit a plaintext node.
|
||||
if n.Security == "" {
|
||||
switch n.Protocol {
|
||||
case model.ProtoTrojan, model.ProtoHysteria, model.ProtoHysteria2,
|
||||
model.ProtoTUIC, model.ProtoAnyTLS:
|
||||
n.Security = "tls"
|
||||
}
|
||||
}
|
||||
switch n.Protocol {
|
||||
case model.ProtoHysteria, model.ProtoHysteria2, model.ProtoTUIC, model.ProtoAnyTLS,
|
||||
model.ProtoWireGuard, model.ProtoSOCKS, model.ProtoHTTP, model.ProtoSSH:
|
||||
// QUIC and plain-TCP protocols have no stream transport to speak of.
|
||||
n.Network = ""
|
||||
case model.ProtoVLESS, model.ProtoVMess, model.ProtoTrojan:
|
||||
if n.Network == "" {
|
||||
n.Network = "tcp"
|
||||
}
|
||||
}
|
||||
if n.Name == "" {
|
||||
n.Name = parseFallbackName(n)
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
|
||||
// parseClashTransport lifts the per-network *-opts blocks into the flat
|
||||
// transport fields on Node.
|
||||
func parseClashTransport(n *model.Node, p map[string]any) {
|
||||
if ws := parseAnyMap(parseGet(p, "ws-opts")); ws != nil {
|
||||
n.Path = parseGetString(ws, "path")
|
||||
if h := parseAnyMap(parseGet(ws, "headers")); h != nil {
|
||||
n.Host = parseClashHeader(h, "Host")
|
||||
}
|
||||
if v := parseGet(ws, "max-early-data"); v != nil {
|
||||
parseSetExtra(n, "max-early-data", parseAnyInt(v))
|
||||
}
|
||||
if v := parseGetString(ws, "early-data-header-name"); v != "" {
|
||||
parseSetExtra(n, "early-data-header-name", v)
|
||||
}
|
||||
}
|
||||
if v := parseGetString(p, "ws-path"); v != "" && n.Path == "" { // pre-Meta spelling
|
||||
n.Path = v
|
||||
}
|
||||
if grpc := parseAnyMap(parseGet(p, "grpc-opts")); grpc != nil {
|
||||
n.ServiceName = parseGetString(grpc, "grpc-service-name", "serviceName", "service-name")
|
||||
}
|
||||
if h2 := parseAnyMap(parseGet(p, "h2-opts")); h2 != nil {
|
||||
n.Path = parseGetString(h2, "path")
|
||||
if hosts := parseAnyStringList(parseGet(h2, "host")); len(hosts) > 0 {
|
||||
n.Host = hosts[0]
|
||||
}
|
||||
}
|
||||
if hu := parseAnyMap(parseGet(p, "http-opts")); hu != nil {
|
||||
if paths := parseAnyStringList(parseGet(hu, "path")); len(paths) > 0 {
|
||||
n.Path = paths[0]
|
||||
}
|
||||
if h := parseAnyMap(parseGet(hu, "headers")); h != nil {
|
||||
n.Host = parseClashHeader(h, "Host")
|
||||
}
|
||||
if m := parseGetString(hu, "method"); m != "" {
|
||||
parseSetExtra(n, "http-method", m)
|
||||
}
|
||||
}
|
||||
if up := parseAnyMap(parseGet(p, "httpupgrade-opts")); up != nil {
|
||||
n.Path = parseGetString(up, "path")
|
||||
if h := parseGetString(up, "host"); h != "" {
|
||||
n.Host = h
|
||||
}
|
||||
}
|
||||
if rs := parseAnyMap(parseGet(p, "reality-opts")); rs == nil && n.Security == "" {
|
||||
if parseGetString(p, "servername") != "" && n.Protocol == model.ProtoVLESS {
|
||||
// Mihomo treats a servername without tls:true as implicit TLS.
|
||||
n.Security = "tls"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parseClashHeader(headers map[string]any, want string) string {
|
||||
for k, v := range headers {
|
||||
if strings.EqualFold(k, want) {
|
||||
if list := parseAnyStringList(v); len(list) > 0 {
|
||||
return list[0]
|
||||
}
|
||||
return parseAnyString(v)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// parseClashPerProtocol handles the fields that only make sense for one type.
|
||||
func parseClashPerProtocol(n *model.Node, p map[string]any) {
|
||||
switch n.Protocol {
|
||||
case model.ProtoHysteria:
|
||||
if v := parseGetString(p, "auth-str", "auth_str", "auth"); v != "" {
|
||||
n.Password = v
|
||||
}
|
||||
if v := parseGetString(p, "protocol"); v != "" {
|
||||
parseSetExtra(n, "protocol", v)
|
||||
}
|
||||
case model.ProtoSSR:
|
||||
if v := parseGetString(p, "protocol"); v != "" {
|
||||
parseSetExtra(n, "protocol", v)
|
||||
}
|
||||
if v := parseGetString(p, "protocol-param", "protocolparam"); v != "" {
|
||||
parseSetExtra(n, "protocol_param", v)
|
||||
}
|
||||
if v := parseGetString(p, "obfs-param", "obfsparam"); v != "" {
|
||||
parseSetExtra(n, "obfs_param", v)
|
||||
n.ObfsPassword = ""
|
||||
}
|
||||
case model.ProtoShadowsock:
|
||||
if v := parseGetString(p, "plugin"); v != "" {
|
||||
parseSetExtra(n, "plugin", v)
|
||||
if opts := parseAnyMap(parseGet(p, "plugin-opts")); opts != nil {
|
||||
parseSetExtra(n, "plugin-opts", opts)
|
||||
if h := parseGetString(opts, "host"); h != "" && n.Host == "" {
|
||||
n.Host = h
|
||||
}
|
||||
if pt := parseGetString(opts, "path"); pt != "" && n.Path == "" {
|
||||
n.Path = pt
|
||||
}
|
||||
if md := parseGetString(opts, "mode"); md == "websocket" && n.Network == "tcp" {
|
||||
n.Network = "ws"
|
||||
}
|
||||
}
|
||||
}
|
||||
case model.ProtoSOCKS, model.ProtoHTTP:
|
||||
if v := parseGetString(p, "username"); v != "" {
|
||||
parseSetExtra(n, "username", v)
|
||||
}
|
||||
case model.ProtoWireGuard:
|
||||
if v := parseGetString(p, "private-key", "privateKey"); v != "" {
|
||||
parseSetExtra(n, "private_key", v)
|
||||
}
|
||||
if v := parseGetString(p, "public-key", "publicKey"); v != "" {
|
||||
n.PublicKey = v
|
||||
}
|
||||
if v := parseGetString(p, "pre-shared-key", "preSharedKey"); v != "" {
|
||||
parseSetExtra(n, "pre_shared_key", v)
|
||||
}
|
||||
if v := parseAnyStringList(parseGet(p, "ip", "address")); len(v) > 0 {
|
||||
parseSetExtra(n, "address", v)
|
||||
}
|
||||
if v := parseGetInt(p, "mtu"); v > 0 {
|
||||
parseSetExtra(n, "mtu", v)
|
||||
}
|
||||
if v := parseGet(p, "reserved"); v != nil {
|
||||
parseSetExtra(n, "reserved", parseAnyStringList(v))
|
||||
}
|
||||
case model.ProtoTUIC:
|
||||
if v := parseGetString(p, "token"); v != "" && n.Password == "" {
|
||||
n.Password = v
|
||||
}
|
||||
if v := parseGetString(p, "udp-relay-mode"); v != "" {
|
||||
parseSetExtra(n, "udp_relay_mode", v)
|
||||
}
|
||||
}
|
||||
|
||||
if v := parseGet(p, "udp"); v != nil {
|
||||
parseSetExtra(n, "udp", parseAnyBool(v))
|
||||
}
|
||||
if v := parseGetString(p, "packet-encoding"); v != "" {
|
||||
parseSetExtra(n, "packet-encoding", v)
|
||||
}
|
||||
if v := parseGetString(p, "dialer-proxy"); v != "" {
|
||||
parseSetExtra(n, "dialer-proxy", v)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package subfmt
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/omar/prizma/internal/model"
|
||||
)
|
||||
|
||||
// singBoxNonProxy are outbound types that describe local behaviour or a
|
||||
// selection policy rather than a server. They are never nodes.
|
||||
var singBoxNonProxy = map[string]bool{
|
||||
"direct": true,
|
||||
"block": true,
|
||||
"dns": true,
|
||||
"selector": true,
|
||||
"urltest": true,
|
||||
}
|
||||
|
||||
// parseSingBoxDoc reads a sing-box configuration. Both the classic
|
||||
// "outbounds" array and the 1.11+ "endpoints" array (where WireGuard moved)
|
||||
// are scanned.
|
||||
func parseSingBoxDoc(body []byte) ([]model.Node, error) {
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal(body, &doc); err != nil {
|
||||
return nil, fmt.Errorf("subfmt: sing-box json: %w", err)
|
||||
}
|
||||
if doc == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
lists := [][]any{parseAnySlice(doc["outbounds"]), parseAnySlice(doc["endpoints"])}
|
||||
out := make([]model.Node, 0, len(lists[0])+len(lists[1]))
|
||||
for _, list := range lists {
|
||||
for _, item := range list {
|
||||
ob := parseAnyMap(item)
|
||||
if ob == nil {
|
||||
continue
|
||||
}
|
||||
n, ok := parseSingBoxOutbound(ob, doc)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, n)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func parseSingBoxOutbound(ob map[string]any, doc map[string]any) (model.Node, bool) {
|
||||
typ := strings.ToLower(parseGetString(ob, "type"))
|
||||
if typ == "" || singBoxNonProxy[typ] {
|
||||
return model.Node{}, false
|
||||
}
|
||||
server := parseGetString(ob, "server")
|
||||
if server == "" && typ != "wireguard" {
|
||||
return model.Node{}, false
|
||||
}
|
||||
|
||||
n := model.Node{
|
||||
Name: parseCleanName(parseGetString(ob, "tag")),
|
||||
Protocol: parseNormProtocol(typ),
|
||||
Server: server,
|
||||
Port: parseGetInt(ob, "server_port", "server-port"),
|
||||
UUID: parseGetString(ob, "uuid"),
|
||||
Password: parseGetString(ob, "password"),
|
||||
Method: parseGetString(ob, "method"),
|
||||
AlterID: parseGetInt(ob, "alter_id"),
|
||||
Flow: parseGetString(ob, "flow"),
|
||||
Congestion: parseGetString(ob, "congestion_control"),
|
||||
UpMbps: parseGetInt(ob, "up_mbps"),
|
||||
DownMbps: parseGetInt(ob, "down_mbps"),
|
||||
RawSingBox: ob,
|
||||
RawDoc: doc,
|
||||
}
|
||||
if n.Protocol == model.ProtoVMess && n.Method == "" {
|
||||
n.Method = parseGetString(ob, "security")
|
||||
}
|
||||
if v := parseGetString(ob, "auth_str", "auth"); v != "" && n.Password == "" {
|
||||
n.Password = v
|
||||
}
|
||||
|
||||
if tls := parseAnyMap(parseGet(ob, "tls")); tls != nil && parseGetBool(tls, "enabled") {
|
||||
n.Security = "tls"
|
||||
n.SNI = parseGetString(tls, "server_name")
|
||||
n.ALPN = parseAnyStringList(parseGet(tls, "alpn"))
|
||||
n.AllowInsecure = parseGetBool(tls, "insecure")
|
||||
if utls := parseAnyMap(parseGet(tls, "utls")); utls != nil {
|
||||
n.Fingerprint = parseGetString(utls, "fingerprint")
|
||||
}
|
||||
if reality := parseAnyMap(parseGet(tls, "reality")); reality != nil && parseGetBool(reality, "enabled") {
|
||||
n.Security = "reality"
|
||||
n.PublicKey = parseGetString(reality, "public_key")
|
||||
n.ShortID = parseGetString(reality, "short_id")
|
||||
}
|
||||
}
|
||||
|
||||
if tr := parseAnyMap(parseGet(ob, "transport")); tr != nil {
|
||||
n.Network = parseNormNetwork(parseGetString(tr, "type"))
|
||||
n.Path = parseGetString(tr, "path")
|
||||
n.ServiceName = parseGetString(tr, "service_name")
|
||||
if h := parseAnyMap(parseGet(tr, "headers")); h != nil {
|
||||
n.Host = parseClashHeader(h, "Host")
|
||||
}
|
||||
if n.Host == "" {
|
||||
if hosts := parseAnyStringList(parseGet(tr, "host")); len(hosts) > 0 {
|
||||
n.Host = hosts[0]
|
||||
}
|
||||
}
|
||||
} else if typ == "vless" || typ == "vmess" || typ == "trojan" {
|
||||
n.Network = "tcp"
|
||||
}
|
||||
|
||||
if obfs := parseAnyMap(parseGet(ob, "obfs")); obfs != nil {
|
||||
n.Obfs = parseGetString(obfs, "type")
|
||||
n.ObfsPassword = parseGetString(obfs, "password")
|
||||
}
|
||||
|
||||
switch typ {
|
||||
case "wireguard":
|
||||
if v := parseGetString(ob, "private_key"); v != "" {
|
||||
parseSetExtra(&n, "private_key", v)
|
||||
}
|
||||
if peers := parseAnySlice(parseGet(ob, "peers")); len(peers) > 0 {
|
||||
peer := parseAnyMap(peers[0])
|
||||
if n.Server == "" {
|
||||
n.Server = parseGetString(peer, "address", "server")
|
||||
n.Port = parseGetInt(peer, "port", "server_port")
|
||||
}
|
||||
n.PublicKey = parseGetString(peer, "public_key")
|
||||
if v := parseGetString(peer, "pre_shared_key"); v != "" {
|
||||
parseSetExtra(&n, "pre_shared_key", v)
|
||||
}
|
||||
if v := parseAnyStringList(parseGet(peer, "reserved")); len(v) > 0 {
|
||||
parseSetExtra(&n, "reserved", v)
|
||||
}
|
||||
}
|
||||
if v := parseAnyStringList(parseGet(ob, "address", "local_address")); len(v) > 0 {
|
||||
parseSetExtra(&n, "address", v)
|
||||
}
|
||||
if v := parseGetInt(ob, "mtu"); v > 0 {
|
||||
parseSetExtra(&n, "mtu", v)
|
||||
}
|
||||
case "socks", "http":
|
||||
if v := parseGetString(ob, "username"); v != "" {
|
||||
parseSetExtra(&n, "username", v)
|
||||
}
|
||||
if v := parseGetString(ob, "version"); v != "" {
|
||||
parseSetExtra(&n, "socks_version", v)
|
||||
}
|
||||
case "shadowsocksr":
|
||||
if v := parseGetString(ob, "protocol"); v != "" {
|
||||
parseSetExtra(&n, "protocol", v)
|
||||
}
|
||||
if v := parseGetString(ob, "protocol_param"); v != "" {
|
||||
parseSetExtra(&n, "protocol_param", v)
|
||||
}
|
||||
if v := parseGetString(ob, "obfs_param"); v != "" {
|
||||
parseSetExtra(&n, "obfs_param", v)
|
||||
}
|
||||
case "ssh":
|
||||
if v := parseGetString(ob, "user"); v != "" {
|
||||
parseSetExtra(&n, "username", v)
|
||||
}
|
||||
}
|
||||
|
||||
if n.Name == "" {
|
||||
n.Name = parseFallbackName(n)
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
@@ -0,0 +1,855 @@
|
||||
package subfmt
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/omar/prizma/internal/model"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrEmptyLine marks a line that carries no node at all (blank, comment).
|
||||
ErrEmptyLine = errors.New("subfmt: blank or comment line")
|
||||
// ErrNotAURI marks a line without a "scheme://" prefix.
|
||||
ErrNotAURI = errors.New("subfmt: not a proxy URI")
|
||||
// ErrBadURI marks a URI whose payload could not be decoded.
|
||||
ErrBadURI = errors.New("subfmt: malformed proxy URI")
|
||||
)
|
||||
|
||||
// parseURIParts is the hand-rolled split of a share link.
|
||||
//
|
||||
// net/url is deliberately not used: shadowsocks and wireguard put raw base64 —
|
||||
// '/' and '+' included — in the userinfo, which url.Parse rejects outright.
|
||||
// Losing a quarter of a subscription to a spec-correct parser is not a trade
|
||||
// Prizma can make.
|
||||
type parseURIParts struct {
|
||||
Scheme string
|
||||
User string // raw userinfo, still percent-encoded / base64
|
||||
Host string
|
||||
Port int
|
||||
Path string
|
||||
RawQuery string
|
||||
Query url.Values
|
||||
QLower map[string]string // lowercased keys; panels disagree on case
|
||||
Name string // percent-decoded fragment
|
||||
}
|
||||
|
||||
// parseSplitURI cuts a share link into its parts without validating any of
|
||||
// them. Order matters: the fragment and query come off first because neither
|
||||
// character can occur inside base64, then the LAST '@' separates userinfo from
|
||||
// the authority so an '@' inside a password stays with the password.
|
||||
func parseSplitURI(raw string) (parseURIParts, error) {
|
||||
var p parseURIParts
|
||||
i := strings.Index(raw, "://")
|
||||
if i <= 0 {
|
||||
return p, ErrNotAURI
|
||||
}
|
||||
p.Scheme = strings.ToLower(raw[:i])
|
||||
rest := raw[i+3:]
|
||||
|
||||
if h := strings.Index(rest, "#"); h >= 0 {
|
||||
p.Name = parseCleanName(parsePercentDecode(rest[h+1:]))
|
||||
rest = rest[:h]
|
||||
}
|
||||
if q := strings.Index(rest, "?"); q >= 0 {
|
||||
p.RawQuery = rest[q+1:]
|
||||
rest = rest[:q]
|
||||
}
|
||||
if at := strings.LastIndex(rest, "@"); at >= 0 {
|
||||
p.User = rest[:at]
|
||||
rest = rest[at+1:]
|
||||
}
|
||||
if s := strings.Index(rest, "/"); s >= 0 {
|
||||
p.Path = rest[s:]
|
||||
rest = rest[:s]
|
||||
}
|
||||
p.Host, p.Port = parseSplitHostPort(rest)
|
||||
|
||||
// ParseQuery reports an error for a malformed pair but still returns the
|
||||
// pairs it understood, which is exactly the salvage behaviour wanted here.
|
||||
p.Query, _ = url.ParseQuery(p.RawQuery)
|
||||
p.QLower = make(map[string]string, len(p.Query))
|
||||
for k, v := range p.Query {
|
||||
if len(v) == 0 {
|
||||
continue
|
||||
}
|
||||
lk := strings.ToLower(k)
|
||||
if _, dup := p.QLower[lk]; !dup {
|
||||
p.QLower[lk] = v[0]
|
||||
}
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// ParseURI converts a single proxy URI line into a Node. The line is always
|
||||
// preserved in RawURI so a URI-family renderer can emit it byte-for-byte and
|
||||
// an unknown scheme survives the round trip untouched.
|
||||
func ParseURI(line string) (model.Node, error) {
|
||||
raw := strings.TrimSpace(line)
|
||||
if raw == "" || strings.HasPrefix(raw, "#") || strings.HasPrefix(raw, "//") {
|
||||
return model.Node{}, ErrEmptyLine
|
||||
}
|
||||
i := strings.Index(raw, "://")
|
||||
if i <= 0 {
|
||||
return model.Node{}, ErrNotAURI
|
||||
}
|
||||
|
||||
n := model.Node{RawURI: raw}
|
||||
var err error
|
||||
switch strings.ToLower(raw[:i]) {
|
||||
case "vless":
|
||||
err = parseURIVLESS(raw, &n)
|
||||
case "vmess":
|
||||
err = parseURIVMess(raw, &n)
|
||||
case "trojan", "trojan-go":
|
||||
err = parseURITrojan(raw, &n)
|
||||
case "ss", "shadowsocks":
|
||||
err = parseURISS(raw, &n)
|
||||
case "ssr":
|
||||
err = parseURISSR(raw, &n)
|
||||
case "hysteria", "hy":
|
||||
err = parseURIHysteria(raw, &n)
|
||||
case "hysteria2", "hy2":
|
||||
err = parseURIHysteria2(raw, &n)
|
||||
case "tuic":
|
||||
err = parseURITUIC(raw, &n)
|
||||
case "anytls":
|
||||
err = parseURIAnyTLS(raw, &n)
|
||||
case "socks", "socks5", "socks4", "socks4a", "socks5h":
|
||||
err = parseURISOCKS(raw, &n)
|
||||
case "http", "https":
|
||||
err = parseURIHTTP(raw, &n)
|
||||
case "wireguard", "wg":
|
||||
err = parseURIWireGuard(raw, &n)
|
||||
case "ssh":
|
||||
err = parseURISSH(raw, &n)
|
||||
default:
|
||||
err = parseURIGeneric(raw, &n)
|
||||
}
|
||||
if err != nil {
|
||||
return model.Node{}, err
|
||||
}
|
||||
|
||||
n.RawURI = raw
|
||||
if n.Name == "" {
|
||||
n.Name = parseFallbackName(n)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// shared query handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// stdQueryConsumed lists the query keys parseApplyStdQuery maps onto a
|
||||
// normalized Node field. Everything else is copied into Extra verbatim so a
|
||||
// cross-format renderer can still see it.
|
||||
var stdQueryConsumed = map[string]bool{
|
||||
"type": true, "net": true, "network": true,
|
||||
"security": true, "tls": true,
|
||||
"sni": true, "peer": true, "servername": true, "server-name": true,
|
||||
"host": true, "path": true,
|
||||
"servicename": true, "service-name": true,
|
||||
"headertype": true, "header-type": true, "seed": true,
|
||||
"alpn": true,
|
||||
"fp": true, "fingerprint": true, "client-fingerprint": true,
|
||||
"pbk": true, "publickey": true, "public-key": true,
|
||||
"sid": true, "shortid": true, "short-id": true,
|
||||
"spx": true, "spiderx": true, "spider-x": true,
|
||||
"flow": true,
|
||||
"encryption": true,
|
||||
"allowinsecure": true, "allow_insecure": true, "allow-insecure": true,
|
||||
"insecure": true, "skip-cert-verify": true, "skipcertverify": true,
|
||||
}
|
||||
|
||||
// parseApplyStdQuery fills the transport/TLS block shared by the v2rayN-style
|
||||
// share links (vless, vmess, trojan, ss-with-plugin, anytls …). Keys named in
|
||||
// alsoConsumed belong to the calling protocol and are kept out of Extra.
|
||||
func parseApplyStdQuery(n *model.Node, p parseURIParts, alsoConsumed ...string) {
|
||||
q := p.QLower
|
||||
|
||||
if v := parseFirstNonEmpty(q["type"], q["net"], q["network"]); v != "" {
|
||||
n.Network = parseNormNetwork(v)
|
||||
}
|
||||
if v := q["security"]; v != "" {
|
||||
n.Security = strings.ToLower(v)
|
||||
} else if parseBoolString(q["tls"]) {
|
||||
n.Security = "tls"
|
||||
} else if q["pbk"] != "" {
|
||||
// "pbk" is the reality-only spelling; "publickey" is not, wireguard
|
||||
// uses it for something else entirely.
|
||||
n.Security = "reality"
|
||||
}
|
||||
|
||||
// Every assignment below is conditional: a protocol handler may have filled
|
||||
// the field from the userinfo or from its own settings already, and an
|
||||
// absent query key must not erase that.
|
||||
parseSetIf(&n.SNI, parseFirstNonEmpty(q["sni"], q["peer"], q["servername"], q["server-name"]))
|
||||
parseSetIf(&n.Host, q["host"])
|
||||
parseSetIf(&n.Path, q["path"])
|
||||
parseSetIf(&n.ServiceName, parseFirstNonEmpty(q["servicename"], q["service-name"]))
|
||||
parseSetIf(&n.HeaderType, parseFirstNonEmpty(q["headertype"], q["header-type"]))
|
||||
parseSetIf(&n.Seed, q["seed"])
|
||||
parseSetIf(&n.Fingerprint, parseFirstNonEmpty(q["fp"], q["fingerprint"], q["client-fingerprint"]))
|
||||
parseSetIf(&n.PublicKey, parseFirstNonEmpty(q["pbk"], q["publickey"], q["public-key"]))
|
||||
parseSetIf(&n.ShortID, parseFirstNonEmpty(q["sid"], q["shortid"], q["short-id"]))
|
||||
parseSetIf(&n.SpiderX, parseFirstNonEmpty(q["spx"], q["spiderx"], q["spider-x"]))
|
||||
parseSetIf(&n.Flow, q["flow"])
|
||||
if alpn := parseCommaList(q["alpn"]); alpn != nil {
|
||||
n.ALPN = alpn
|
||||
}
|
||||
if v := parseFirstNonEmpty(
|
||||
q["allowinsecure"], q["allow_insecure"], q["allow-insecure"],
|
||||
q["insecure"], q["skip-cert-verify"], q["skipcertverify"]); v != "" {
|
||||
n.AllowInsecure = parseBoolString(v)
|
||||
}
|
||||
|
||||
// gRPC carries its virtual host in "authority" rather than "host".
|
||||
if n.Host == "" && n.Network == "grpc" {
|
||||
n.Host = q["authority"]
|
||||
}
|
||||
// A vless "encryption" other than none is meaningful (post-quantum modes),
|
||||
// the default is noise. On vmess the same key is just the cipher, which
|
||||
// already has a home in Method.
|
||||
if enc := q["encryption"]; enc != "" && enc != "none" && n.Protocol != model.ProtoVMess {
|
||||
parseSetExtra(n, "encryption", enc)
|
||||
}
|
||||
|
||||
extraConsumed := make(map[string]bool, len(alsoConsumed))
|
||||
for _, k := range alsoConsumed {
|
||||
extraConsumed[strings.ToLower(k)] = true
|
||||
}
|
||||
for k, v := range p.Query {
|
||||
lk := strings.ToLower(k)
|
||||
if stdQueryConsumed[lk] || extraConsumed[lk] || len(v) == 0 || v[0] == "" {
|
||||
continue
|
||||
}
|
||||
parseSetExtra(n, k, v[0])
|
||||
}
|
||||
}
|
||||
|
||||
// parseSetIf assigns only when the incoming value carries information.
|
||||
func parseSetIf(dst *string, v string) {
|
||||
if v != "" {
|
||||
*dst = v
|
||||
}
|
||||
}
|
||||
|
||||
func parseFirstNonEmpty(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// per-scheme parsers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func parseURIVLESS(raw string, n *model.Node) error {
|
||||
p, err := parseSplitURI(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p.Host == "" || p.User == "" {
|
||||
return ErrBadURI
|
||||
}
|
||||
n.Protocol = model.ProtoVLESS
|
||||
n.Name = p.Name
|
||||
n.Server, n.Port = p.Host, p.Port
|
||||
n.UUID = parsePercentDecode(p.User)
|
||||
parseApplyStdQuery(n, p)
|
||||
if n.Network == "" {
|
||||
n.Network = "tcp"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseURIVMess handles all three vmess link dialects: the base64 JSON blob
|
||||
// v2rayN invented, the plain "vmess://uuid@host:port?…" AEAD link, and the
|
||||
// Shadowrocket "vmess://base64(cipher:uuid@host:port)?…" form.
|
||||
func parseURIVMess(raw string, n *model.Node) error {
|
||||
body := raw[len("vmess://"):]
|
||||
head := body
|
||||
if i := strings.IndexAny(head, "?#"); i >= 0 {
|
||||
head = head[:i]
|
||||
}
|
||||
|
||||
if strings.Contains(head, "@") {
|
||||
p, err := parseSplitURI(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p.Host == "" {
|
||||
return ErrBadURI
|
||||
}
|
||||
n.Protocol = model.ProtoVMess
|
||||
n.Name = p.Name
|
||||
n.Server, n.Port = p.Host, p.Port
|
||||
n.UUID = parsePercentDecode(p.User)
|
||||
n.AlterID = parseAtoi(parseFirstNonEmpty(p.QLower["aid"], p.QLower["alterid"]))
|
||||
n.Method = parseFirstNonEmpty(p.QLower["encryption"], p.QLower["scy"], p.QLower["security"])
|
||||
parseApplyStdQuery(n, p, "aid", "alterid", "scy")
|
||||
// "security" on a vmess link is the cipher, not the TLS mode; the TLS
|
||||
// mode arrives as tls=1 or as its own security=tls value.
|
||||
if n.Method != "" && n.Security == strings.ToLower(n.Method) {
|
||||
n.Security = ""
|
||||
if parseBoolString(p.QLower["tls"]) {
|
||||
n.Security = "tls"
|
||||
}
|
||||
}
|
||||
if n.Network == "" {
|
||||
n.Network = "tcp"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
dec, ok := b64Decode(head)
|
||||
if !ok {
|
||||
return ErrBadURI
|
||||
}
|
||||
trimmed := strings.TrimSpace(string(dec))
|
||||
if strings.HasPrefix(trimmed, "{") {
|
||||
return parseVMessJSON([]byte(trimmed), n)
|
||||
}
|
||||
|
||||
// cipher:uuid@host:port
|
||||
at := strings.LastIndex(trimmed, "@")
|
||||
colon := strings.Index(trimmed, ":")
|
||||
if at < 0 || colon < 0 || colon > at {
|
||||
return ErrBadURI
|
||||
}
|
||||
n.Protocol = model.ProtoVMess
|
||||
n.Method = trimmed[:colon]
|
||||
n.UUID = trimmed[colon+1 : at]
|
||||
n.Server, n.Port = parseSplitHostPort(trimmed[at+1:])
|
||||
|
||||
p, err := parseSplitURI(raw)
|
||||
if err == nil {
|
||||
n.Name = p.Name
|
||||
n.AlterID = parseAtoi(parseFirstNonEmpty(p.QLower["aid"], p.QLower["alterid"]))
|
||||
if v := parseFirstNonEmpty(p.QLower["remarks"], p.QLower["remark"]); v != "" && n.Name == "" {
|
||||
n.Name = parseCleanName(v)
|
||||
}
|
||||
parseApplyStdQuery(n, p, "aid", "alterid", "remarks", "remark", "obfsparam", "obfs")
|
||||
if v := p.QLower["obfs"]; v != "" && n.Network == "" {
|
||||
n.Network = parseNormNetwork(v)
|
||||
}
|
||||
if v := p.QLower["obfsparam"]; v != "" && n.Host == "" {
|
||||
n.Host = v
|
||||
}
|
||||
}
|
||||
if n.Network == "" {
|
||||
n.Network = "tcp"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// vmessJSON is the v2rayN sharing object. Every field is quoted in the wild
|
||||
// but some panels emit real numbers, hence json.Number handling via any.
|
||||
type vmessJSON struct {
|
||||
PS any `json:"ps"`
|
||||
Add any `json:"add"`
|
||||
Port any `json:"port"`
|
||||
ID any `json:"id"`
|
||||
Aid any `json:"aid"`
|
||||
Scy any `json:"scy"`
|
||||
Net any `json:"net"`
|
||||
Type any `json:"type"`
|
||||
Host any `json:"host"`
|
||||
Path any `json:"path"`
|
||||
TLS any `json:"tls"`
|
||||
SNI any `json:"sni"`
|
||||
ALPN any `json:"alpn"`
|
||||
FP any `json:"fp"`
|
||||
V any `json:"v"`
|
||||
}
|
||||
|
||||
func parseVMessJSON(b []byte, n *model.Node) error {
|
||||
var v vmessJSON
|
||||
if err := json.Unmarshal(b, &v); err != nil {
|
||||
return ErrBadURI
|
||||
}
|
||||
n.Protocol = model.ProtoVMess
|
||||
n.Name = parseCleanName(parseAnyString(v.PS))
|
||||
n.Server = parseAnyString(v.Add)
|
||||
n.Port = parseAnyInt(v.Port)
|
||||
n.UUID = parseAnyString(v.ID)
|
||||
n.AlterID = parseAnyInt(v.Aid)
|
||||
n.Method = parseAnyString(v.Scy)
|
||||
n.Network = parseNormNetwork(parseAnyString(v.Net))
|
||||
n.HeaderType = parseAnyString(v.Type)
|
||||
n.Host = parseAnyString(v.Host)
|
||||
n.Path = parseAnyString(v.Path)
|
||||
n.SNI = parseAnyString(v.SNI)
|
||||
n.ALPN = parseCommaList(parseAnyString(v.ALPN))
|
||||
n.Fingerprint = parseAnyString(v.FP)
|
||||
if tls := strings.ToLower(parseAnyString(v.TLS)); tls != "" && tls != "none" {
|
||||
n.Security = tls
|
||||
}
|
||||
// kcp reuses "type" as the obfuscation header and "path" as the seed.
|
||||
if n.Network == "kcp" && n.Seed == "" {
|
||||
n.Seed = n.Path
|
||||
}
|
||||
if n.Server == "" {
|
||||
return ErrBadURI
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseURITrojan(raw string, n *model.Node) error {
|
||||
p, err := parseSplitURI(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p.Host == "" {
|
||||
return ErrBadURI
|
||||
}
|
||||
n.Protocol = model.ProtoTrojan
|
||||
n.Name = p.Name
|
||||
n.Server, n.Port = p.Host, p.Port
|
||||
n.Password = parsePercentDecode(p.User)
|
||||
parseApplyStdQuery(n, p)
|
||||
if n.Network == "" {
|
||||
n.Network = "tcp"
|
||||
}
|
||||
if n.Security == "" {
|
||||
n.Security = "tls" // trojan is TLS-only by definition
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseURISS covers SIP002 ("ss://base64(method:pass)@host:port") and the
|
||||
// legacy fully-base64 form ("ss://base64(method:pass@host:port)").
|
||||
func parseURISS(raw string, n *model.Node) error {
|
||||
scheme := raw[:strings.Index(raw, "://")]
|
||||
body := raw[len(scheme)+3:]
|
||||
|
||||
var name, rawQuery string
|
||||
if h := strings.Index(body, "#"); h >= 0 {
|
||||
name = parseCleanName(parsePercentDecode(body[h+1:]))
|
||||
body = body[:h]
|
||||
}
|
||||
if q := strings.Index(body, "?"); q >= 0 {
|
||||
rawQuery = body[q+1:]
|
||||
body = body[:q]
|
||||
}
|
||||
|
||||
if !strings.Contains(body, "@") {
|
||||
dec, ok := b64Decode(body)
|
||||
if !ok {
|
||||
return ErrBadURI
|
||||
}
|
||||
body = strings.TrimSpace(string(dec))
|
||||
// The legacy blob may itself carry the fragment and query.
|
||||
if h := strings.Index(body, "#"); h >= 0 {
|
||||
if name == "" {
|
||||
name = parseCleanName(parsePercentDecode(body[h+1:]))
|
||||
}
|
||||
body = body[:h]
|
||||
}
|
||||
if q := strings.Index(body, "?"); q >= 0 {
|
||||
if rawQuery == "" {
|
||||
rawQuery = body[q+1:]
|
||||
}
|
||||
body = body[:q]
|
||||
}
|
||||
}
|
||||
|
||||
at := strings.LastIndex(body, "@")
|
||||
if at < 0 {
|
||||
return ErrBadURI
|
||||
}
|
||||
method, password, ok := parseSSUserInfo(body[:at])
|
||||
if !ok {
|
||||
return ErrBadURI
|
||||
}
|
||||
host, port := parseSplitHostPort(body[at+1:])
|
||||
if host == "" {
|
||||
return ErrBadURI
|
||||
}
|
||||
|
||||
n.Protocol = model.ProtoShadowsock
|
||||
n.Name = name
|
||||
n.Server, n.Port = host, port
|
||||
n.Method, n.Password = method, password
|
||||
|
||||
// Re-use the shared query handling by rebuilding a minimal URI around the
|
||||
// query string; the userinfo was already decoded above.
|
||||
p, _ := parseSplitURI(scheme + "://x@" + body[at+1:] + "?" + rawQuery)
|
||||
parseApplyStdQuery(n, p)
|
||||
if plugin := p.QLower["plugin"]; plugin != "" {
|
||||
parseSSPlugin(n, plugin)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseSSUserInfo accepts the three encodings found in ss userinfo: base64 of
|
||||
// "method:password", percent-encoded plaintext, and bare plaintext.
|
||||
func parseSSUserInfo(u string) (method, password string, ok bool) {
|
||||
if u == "" {
|
||||
return "", "", false
|
||||
}
|
||||
plain := parsePercentDecode(u)
|
||||
if !strings.Contains(plain, ":") {
|
||||
dec, decoded := b64Decode(u)
|
||||
if !decoded {
|
||||
return "", "", false
|
||||
}
|
||||
plain = string(dec)
|
||||
}
|
||||
i := strings.Index(plain, ":")
|
||||
if i < 0 {
|
||||
return "", "", false
|
||||
}
|
||||
return plain[:i], plain[i+1:], true
|
||||
}
|
||||
|
||||
// parseSSPlugin unpacks "obfs-local;obfs=http;obfs-host=example.com" style
|
||||
// plugin options into the normalized fields where they have one.
|
||||
func parseSSPlugin(n *model.Node, plugin string) {
|
||||
parts := strings.Split(plugin, ";")
|
||||
parseSetExtra(n, "plugin", parts[0])
|
||||
opts := make(map[string]string, len(parts))
|
||||
for _, kv := range parts[1:] {
|
||||
k, v, _ := strings.Cut(kv, "=")
|
||||
k = strings.TrimSpace(k)
|
||||
if k == "" {
|
||||
continue
|
||||
}
|
||||
opts[k] = v
|
||||
switch strings.ToLower(k) {
|
||||
case "obfs":
|
||||
n.Obfs = v
|
||||
case "obfs-host":
|
||||
if n.Host == "" {
|
||||
n.Host = v
|
||||
}
|
||||
case "path":
|
||||
if n.Path == "" {
|
||||
n.Path = v
|
||||
}
|
||||
case "host":
|
||||
if n.Host == "" {
|
||||
n.Host = v
|
||||
}
|
||||
case "mode":
|
||||
if v == "websocket" && n.Network == "" {
|
||||
n.Network = "ws"
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(opts) > 0 {
|
||||
parseSetExtra(n, "plugin-opts", opts)
|
||||
}
|
||||
}
|
||||
|
||||
// parseURISSR decodes the doubly base64-encoded SSR link:
|
||||
// ssr://b64(host:port:protocol:method:obfs:b64(password)/?obfsparam=b64&…)
|
||||
func parseURISSR(raw string, n *model.Node) error {
|
||||
dec, ok := b64Decode(raw[len("ssr://"):])
|
||||
if !ok {
|
||||
return ErrBadURI
|
||||
}
|
||||
body := strings.TrimSpace(string(dec))
|
||||
|
||||
main, query := body, ""
|
||||
if i := strings.Index(body, "/?"); i >= 0 {
|
||||
main, query = body[:i], body[i+2:]
|
||||
} else if i := strings.Index(body, "?"); i >= 0 {
|
||||
main, query = body[:i], body[i+1:]
|
||||
}
|
||||
main = strings.TrimSuffix(main, "/")
|
||||
|
||||
fields := strings.Split(main, ":")
|
||||
if len(fields) < 6 {
|
||||
return ErrBadURI
|
||||
}
|
||||
// The host may itself contain ':' (IPv6), so the six meaningful fields are
|
||||
// counted from the right.
|
||||
tail := fields[len(fields)-5:]
|
||||
host := strings.Join(fields[:len(fields)-5], ":")
|
||||
host = strings.Trim(host, "[]")
|
||||
|
||||
n.Protocol = model.ProtoSSR
|
||||
n.Server = host
|
||||
n.Port = parseAtoi(tail[0])
|
||||
n.Method = tail[2]
|
||||
n.Obfs = tail[3]
|
||||
n.Password = b64DecodeString(tail[4])
|
||||
parseSetExtra(n, "protocol", tail[1]) // SSR's own "protocol" plugin
|
||||
|
||||
q, _ := url.ParseQuery(query)
|
||||
if v := q.Get("remarks"); v != "" {
|
||||
n.Name = parseCleanName(b64DecodeString(v))
|
||||
}
|
||||
if v := q.Get("obfsparam"); v != "" {
|
||||
parseSetExtra(n, "obfs_param", b64DecodeString(v))
|
||||
}
|
||||
if v := q.Get("protoparam"); v != "" {
|
||||
parseSetExtra(n, "protocol_param", b64DecodeString(v))
|
||||
}
|
||||
if v := q.Get("group"); v != "" {
|
||||
parseSetExtra(n, "group", b64DecodeString(v))
|
||||
}
|
||||
if n.Server == "" {
|
||||
return ErrBadURI
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseURIHysteria(raw string, n *model.Node) error {
|
||||
p, err := parseSplitURI(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p.Host == "" {
|
||||
return ErrBadURI
|
||||
}
|
||||
q := p.QLower
|
||||
n.Protocol = model.ProtoHysteria
|
||||
n.Name = p.Name
|
||||
n.Server, n.Port = p.Host, p.Port
|
||||
// Hysteria 1 puts the credential in "auth"/"auth_str"/"authStr", never in
|
||||
// the userinfo.
|
||||
n.Password = parseFirstNonEmpty(parsePercentDecode(p.User),
|
||||
q["auth"], q["auth_str"], q["authstr"])
|
||||
n.UpMbps = parseLeadingInt(parseFirstNonEmpty(q["upmbps"], q["up"], q["up_mbps"]))
|
||||
n.DownMbps = parseLeadingInt(parseFirstNonEmpty(q["downmbps"], q["down"], q["down_mbps"]))
|
||||
n.Obfs = parseFirstNonEmpty(q["obfs"], q["obfsparam"])
|
||||
parseApplyStdQuery(n, p,
|
||||
"auth", "auth_str", "authstr", "upmbps", "up", "up_mbps",
|
||||
"downmbps", "down", "down_mbps", "obfs", "obfsparam")
|
||||
if n.Security == "" {
|
||||
n.Security = "tls"
|
||||
}
|
||||
n.Network = ""
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseURIHysteria2(raw string, n *model.Node) error {
|
||||
p, err := parseSplitURI(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p.Host == "" {
|
||||
return ErrBadURI
|
||||
}
|
||||
q := p.QLower
|
||||
n.Protocol = model.ProtoHysteria2
|
||||
n.Name = p.Name
|
||||
n.Server, n.Port = p.Host, p.Port
|
||||
n.Password = parseFirstNonEmpty(parsePercentDecode(p.User), q["auth"], q["password"])
|
||||
n.Obfs = q["obfs"]
|
||||
n.ObfsPassword = parseFirstNonEmpty(q["obfs-password"], q["obfs_password"], q["obfspassword"])
|
||||
n.UpMbps = parseLeadingInt(parseFirstNonEmpty(q["up"], q["upmbps"], q["up_mbps"]))
|
||||
n.DownMbps = parseLeadingInt(parseFirstNonEmpty(q["down"], q["downmbps"], q["down_mbps"]))
|
||||
parseApplyStdQuery(n, p,
|
||||
"auth", "password", "obfs", "obfs-password", "obfs_password", "obfspassword",
|
||||
"up", "upmbps", "up_mbps", "down", "downmbps", "down_mbps")
|
||||
if n.Security == "" {
|
||||
n.Security = "tls"
|
||||
}
|
||||
n.Network = ""
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseURITUIC(raw string, n *model.Node) error {
|
||||
p, err := parseSplitURI(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p.Host == "" {
|
||||
return ErrBadURI
|
||||
}
|
||||
q := p.QLower
|
||||
n.Protocol = model.ProtoTUIC
|
||||
n.Name = p.Name
|
||||
n.Server, n.Port = p.Host, p.Port
|
||||
if uuid, pass, found := strings.Cut(p.User, ":"); found {
|
||||
n.UUID = parsePercentDecode(uuid)
|
||||
n.Password = parsePercentDecode(pass)
|
||||
} else {
|
||||
n.UUID = parsePercentDecode(p.User)
|
||||
n.Password = parseFirstNonEmpty(q["password"], q["token"])
|
||||
}
|
||||
n.Congestion = parseFirstNonEmpty(q["congestion_control"], q["congestion-control"], q["congestion"])
|
||||
parseApplyStdQuery(n, p, "password", "token",
|
||||
"congestion_control", "congestion-control", "congestion")
|
||||
if n.Security == "" {
|
||||
n.Security = "tls"
|
||||
}
|
||||
n.Network = ""
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseURIAnyTLS(raw string, n *model.Node) error {
|
||||
p, err := parseSplitURI(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p.Host == "" {
|
||||
return ErrBadURI
|
||||
}
|
||||
n.Protocol = model.ProtoAnyTLS
|
||||
n.Name = p.Name
|
||||
n.Server, n.Port = p.Host, p.Port
|
||||
n.Password = parseFirstNonEmpty(parsePercentDecode(p.User), p.QLower["password"])
|
||||
parseApplyStdQuery(n, p, "password")
|
||||
if n.Security == "" {
|
||||
n.Security = "tls"
|
||||
}
|
||||
n.Network = ""
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseURISOCKS(raw string, n *model.Node) error {
|
||||
p, err := parseSplitURI(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p.Host == "" {
|
||||
return ErrBadURI
|
||||
}
|
||||
n.Protocol = model.ProtoSOCKS
|
||||
n.Name = p.Name
|
||||
n.Server, n.Port = p.Host, p.Port
|
||||
parseURICredentials(n, p.User)
|
||||
parseApplyStdQuery(n, p)
|
||||
if p.Scheme == "socks4" || p.Scheme == "socks4a" {
|
||||
parseSetExtra(n, "socks_version", p.Scheme)
|
||||
}
|
||||
n.Network = ""
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseURIHTTP(raw string, n *model.Node) error {
|
||||
p, err := parseSplitURI(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p.Host == "" {
|
||||
return ErrBadURI
|
||||
}
|
||||
n.Protocol = model.ProtoHTTP
|
||||
n.Name = p.Name
|
||||
n.Server, n.Port = p.Host, p.Port
|
||||
parseURICredentials(n, p.User)
|
||||
parseApplyStdQuery(n, p)
|
||||
if p.Scheme == "https" && n.Security == "" {
|
||||
n.Security = "tls"
|
||||
}
|
||||
if n.Port == 0 {
|
||||
if p.Scheme == "https" {
|
||||
n.Port = 443
|
||||
} else {
|
||||
n.Port = 80
|
||||
}
|
||||
}
|
||||
n.Network = ""
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseURISSH(raw string, n *model.Node) error {
|
||||
p, err := parseSplitURI(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p.Host == "" {
|
||||
return ErrBadURI
|
||||
}
|
||||
n.Protocol = model.ProtoSSH
|
||||
n.Name = p.Name
|
||||
n.Server, n.Port = p.Host, p.Port
|
||||
if n.Port == 0 {
|
||||
n.Port = 22
|
||||
}
|
||||
parseURICredentials(n, p.User)
|
||||
parseApplyStdQuery(n, p)
|
||||
n.Network = ""
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseURIWireGuard reads the AmneziaWG/NekoBox flavoured wireguard link.
|
||||
// The private key lands in Extra rather than Password: Password is the shared
|
||||
// secret slot for the TLS-ish protocols and a renderer must not confuse them.
|
||||
func parseURIWireGuard(raw string, n *model.Node) error {
|
||||
p, err := parseSplitURI(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p.Host == "" {
|
||||
return ErrBadURI
|
||||
}
|
||||
q := p.QLower
|
||||
n.Protocol = model.ProtoWireGuard
|
||||
n.Name = p.Name
|
||||
n.Server, n.Port = p.Host, p.Port
|
||||
if key := parsePercentDecode(p.User); key != "" {
|
||||
parseSetExtra(n, "private_key", key)
|
||||
}
|
||||
n.PublicKey = parseFirstNonEmpty(q["publickey"], q["public-key"], q["public_key"], q["peer"], q["pubkey"])
|
||||
if v := parseFirstNonEmpty(q["presharedkey"], q["pre-shared-key"], q["preshared_key"], q["psk"]); v != "" {
|
||||
parseSetExtra(n, "pre_shared_key", v)
|
||||
}
|
||||
if v := parseFirstNonEmpty(q["address"], q["ip"], q["local_address"]); v != "" {
|
||||
parseSetExtra(n, "address", parseCommaList(v))
|
||||
}
|
||||
if v := q["mtu"]; v != "" {
|
||||
parseSetExtra(n, "mtu", parseAtoi(v))
|
||||
}
|
||||
if v := q["reserved"]; v != "" {
|
||||
parseSetExtra(n, "reserved", parseCommaList(v))
|
||||
}
|
||||
parseApplyStdQuery(n, p,
|
||||
"publickey", "public-key", "public_key", "peer", "pubkey",
|
||||
"presharedkey", "pre-shared-key", "preshared_key", "psk",
|
||||
"address", "ip", "local_address", "mtu", "reserved")
|
||||
n.Network = ""
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseURIGeneric keeps an unrecognized scheme alive. Everything a renderer
|
||||
// needs is still in RawURI; the normalized fields are best effort so filtering
|
||||
// by name and protocol keeps working.
|
||||
func parseURIGeneric(raw string, n *model.Node) error {
|
||||
p, err := parseSplitURI(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n.Protocol = parseNormProtocol(p.Scheme)
|
||||
n.Name = p.Name
|
||||
n.Server, n.Port = p.Host, p.Port
|
||||
if p.User != "" {
|
||||
parseSetExtra(n, "userinfo", parsePercentDecode(p.User))
|
||||
}
|
||||
parseApplyStdQuery(n, p)
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseURICredentials splits a "user:pass" userinfo, which may additionally be
|
||||
// base64 as NekoBox writes it.
|
||||
func parseURICredentials(n *model.Node, userinfo string) {
|
||||
if userinfo == "" {
|
||||
return
|
||||
}
|
||||
plain := parsePercentDecode(userinfo)
|
||||
if !strings.Contains(plain, ":") {
|
||||
if dec, ok := b64Decode(userinfo); ok && strings.Contains(string(dec), ":") {
|
||||
plain = string(dec)
|
||||
}
|
||||
}
|
||||
user, pass, found := strings.Cut(plain, ":")
|
||||
if !found {
|
||||
parseSetExtra(n, "username", plain)
|
||||
return
|
||||
}
|
||||
if user != "" {
|
||||
parseSetExtra(n, "username", user)
|
||||
}
|
||||
n.Password = pass
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
package subfmt
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/omar/prizma/internal/model"
|
||||
)
|
||||
|
||||
// xrayNonProxy are outbound protocols that describe local behaviour rather
|
||||
// than a server. Happ ships one of each in every config it publishes, and none
|
||||
// of them is a node.
|
||||
var xrayNonProxy = map[string]bool{
|
||||
"freedom": true,
|
||||
"blackhole": true,
|
||||
"dns": true,
|
||||
"loopback": true,
|
||||
}
|
||||
|
||||
// parseXrayDoc reads either shape of Xray JSON:
|
||||
//
|
||||
// - the Happ flavour, a JSON ARRAY where every element is a COMPLETE config
|
||||
// and therefore exactly ONE node — its proxy outbound, labelled by the
|
||||
// element's "remarks", with the whole element kept as RawDoc so a renderer
|
||||
// can reproduce that config's own dns/routing blocks;
|
||||
// - a single classic config object, where every proxy outbound is a node.
|
||||
func parseXrayDoc(body []byte) ([]model.Node, error) {
|
||||
trimmed := bytes.TrimSpace(body)
|
||||
if len(trimmed) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if trimmed[0] == '[' {
|
||||
var arr []map[string]any
|
||||
if err := json.Unmarshal(trimmed, &arr); err != nil {
|
||||
return nil, fmt.Errorf("subfmt: xray json array: %w", err)
|
||||
}
|
||||
out := make([]model.Node, 0, len(arr))
|
||||
for _, cfg := range arr {
|
||||
n, ok := parseXrayConfigNode(cfg)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, n)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal(trimmed, &doc); err != nil {
|
||||
return nil, fmt.Errorf("subfmt: xray json: %w", err)
|
||||
}
|
||||
if doc == nil {
|
||||
return nil, nil
|
||||
}
|
||||
remarks := parseCleanName(parseGetString(doc, "remarks"))
|
||||
outbounds := parseAnySlice(doc["outbounds"])
|
||||
out := make([]model.Node, 0, len(outbounds))
|
||||
for _, item := range outbounds {
|
||||
ob := parseAnyMap(item)
|
||||
if ob == nil {
|
||||
continue
|
||||
}
|
||||
n, ok := parseXrayOutbound(ob)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
n.RawDoc = doc
|
||||
if n.Name == "" {
|
||||
n.Name = remarks
|
||||
}
|
||||
if n.Name == "" {
|
||||
n.Name = parseFallbackName(n)
|
||||
}
|
||||
out = append(out, n)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// parseXrayConfigNode collapses one whole Happ config into a single node: the
|
||||
// first outbound that actually reaches a server. Balancer configs list a dozen
|
||||
// of them; the first is the representative one and the rest stay reachable
|
||||
// through RawDoc.
|
||||
func parseXrayConfigNode(cfg map[string]any) (model.Node, bool) {
|
||||
for _, item := range parseAnySlice(cfg["outbounds"]) {
|
||||
ob := parseAnyMap(item)
|
||||
if ob == nil {
|
||||
continue
|
||||
}
|
||||
n, ok := parseXrayOutbound(ob)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
n.RawDoc = cfg
|
||||
if name := parseCleanName(parseGetString(cfg, "remarks")); name != "" {
|
||||
n.Name = name
|
||||
}
|
||||
if n.Name == "" {
|
||||
n.Name = parseFallbackName(n)
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
return model.Node{}, false
|
||||
}
|
||||
|
||||
func parseXrayOutbound(ob map[string]any) (model.Node, bool) {
|
||||
proto := strings.ToLower(parseGetString(ob, "protocol"))
|
||||
if proto == "" || xrayNonProxy[proto] {
|
||||
return model.Node{}, false
|
||||
}
|
||||
settings := parseAnyMap(parseGet(ob, "settings"))
|
||||
|
||||
n := model.Node{
|
||||
Name: parseCleanName(parseGetString(ob, "tag")),
|
||||
Protocol: parseNormProtocol(proto),
|
||||
RawXray: ob,
|
||||
}
|
||||
// "proxy" is Happ/v2rayN's boilerplate tag, not a server name.
|
||||
if n.Name == "proxy" {
|
||||
n.Name = ""
|
||||
}
|
||||
|
||||
switch proto {
|
||||
case "vless", "vmess":
|
||||
vnext := parseAnySlice(parseGet(settings, "vnext"))
|
||||
if len(vnext) == 0 {
|
||||
return model.Node{}, false
|
||||
}
|
||||
srv := parseAnyMap(vnext[0])
|
||||
n.Server = parseGetString(srv, "address")
|
||||
n.Port = parseGetInt(srv, "port")
|
||||
if users := parseAnySlice(parseGet(srv, "users")); len(users) > 0 {
|
||||
u := parseAnyMap(users[0])
|
||||
n.UUID = parseGetString(u, "id")
|
||||
n.Flow = parseGetString(u, "flow")
|
||||
n.AlterID = parseGetInt(u, "alterId", "alterid")
|
||||
if proto == "vmess" {
|
||||
n.Method = parseGetString(u, "security")
|
||||
} else if enc := parseGetString(u, "encryption"); enc != "" && enc != "none" {
|
||||
parseSetExtra(&n, "encryption", enc)
|
||||
}
|
||||
}
|
||||
|
||||
case "trojan", "shadowsocks", "socks", "http", "anytls":
|
||||
servers := parseAnySlice(parseGet(settings, "servers"))
|
||||
if len(servers) == 0 {
|
||||
return model.Node{}, false
|
||||
}
|
||||
srv := parseAnyMap(servers[0])
|
||||
n.Server = parseGetString(srv, "address")
|
||||
n.Port = parseGetInt(srv, "port")
|
||||
n.Password = parseGetString(srv, "password")
|
||||
n.Method = parseGetString(srv, "method")
|
||||
if users := parseAnySlice(parseGet(srv, "users")); len(users) > 0 {
|
||||
u := parseAnyMap(users[0])
|
||||
if v := parseGetString(u, "user", "username"); v != "" {
|
||||
parseSetExtra(&n, "username", v)
|
||||
}
|
||||
if v := parseGetString(u, "pass", "password"); v != "" {
|
||||
n.Password = v
|
||||
}
|
||||
}
|
||||
|
||||
case "hysteria", "hysteria2", "tuic":
|
||||
// The Xray forks that speak these protocols put the endpoint straight
|
||||
// on settings and the credentials in streamSettings.
|
||||
n.Server = parseGetString(settings, "address", "server")
|
||||
n.Port = parseGetInt(settings, "port", "server_port")
|
||||
n.Password = parseGetString(settings, "password", "auth", "auth_str")
|
||||
n.UUID = parseGetString(settings, "uuid")
|
||||
if parseGetInt(settings, "version") == 2 {
|
||||
n.Protocol = model.ProtoHysteria2
|
||||
}
|
||||
|
||||
case "wireguard":
|
||||
if v := parseGetString(settings, "secretKey"); v != "" {
|
||||
parseSetExtra(&n, "private_key", v)
|
||||
}
|
||||
if peers := parseAnySlice(parseGet(settings, "peers")); len(peers) > 0 {
|
||||
peer := parseAnyMap(peers[0])
|
||||
n.Server, n.Port = parseSplitHostPort(parseGetString(peer, "endpoint"))
|
||||
n.PublicKey = parseGetString(peer, "publicKey")
|
||||
if v := parseGetString(peer, "preSharedKey"); v != "" {
|
||||
parseSetExtra(&n, "pre_shared_key", v)
|
||||
}
|
||||
}
|
||||
if v := parseAnyStringList(parseGet(settings, "address")); len(v) > 0 {
|
||||
parseSetExtra(&n, "address", v)
|
||||
}
|
||||
if v := parseGetInt(settings, "mtu"); v > 0 {
|
||||
parseSetExtra(&n, "mtu", v)
|
||||
}
|
||||
if v := parseAnyStringList(parseGet(settings, "reserved")); len(v) > 0 {
|
||||
parseSetExtra(&n, "reserved", v)
|
||||
}
|
||||
|
||||
case "ssh":
|
||||
n.Server = parseGetString(settings, "address")
|
||||
n.Port = parseGetInt(settings, "port")
|
||||
if v := parseGetString(settings, "user"); v != "" {
|
||||
parseSetExtra(&n, "username", v)
|
||||
}
|
||||
n.Password = parseGetString(settings, "password")
|
||||
|
||||
default:
|
||||
// An unknown proxy protocol still has a server somewhere; make a best
|
||||
// effort so it can be filtered, and let RawXray carry the truth.
|
||||
n.Server = parseGetString(settings, "address", "server")
|
||||
n.Port = parseGetInt(settings, "port", "server_port")
|
||||
if n.Server == "" {
|
||||
if servers := parseAnySlice(parseGet(settings, "servers")); len(servers) > 0 {
|
||||
srv := parseAnyMap(servers[0])
|
||||
n.Server = parseGetString(srv, "address")
|
||||
n.Port = parseGetInt(srv, "port")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
parseXrayStream(&n, parseAnyMap(parseGet(ob, "streamSettings")))
|
||||
if n.Server == "" {
|
||||
return model.Node{}, false
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
|
||||
func parseXrayStream(n *model.Node, ss map[string]any) {
|
||||
if ss == nil {
|
||||
return
|
||||
}
|
||||
n.Network = parseNormNetwork(parseGetString(ss, "network"))
|
||||
n.Security = strings.ToLower(parseGetString(ss, "security"))
|
||||
|
||||
switch n.Security {
|
||||
case "tls":
|
||||
if tls := parseAnyMap(parseGet(ss, "tlsSettings")); tls != nil {
|
||||
n.SNI = parseGetString(tls, "serverName")
|
||||
n.ALPN = parseAnyStringList(parseGet(tls, "alpn"))
|
||||
n.Fingerprint = parseGetString(tls, "fingerprint")
|
||||
n.AllowInsecure = parseGetBool(tls, "allowInsecure")
|
||||
}
|
||||
case "reality":
|
||||
if r := parseAnyMap(parseGet(ss, "realitySettings")); r != nil {
|
||||
n.SNI = parseGetString(r, "serverName")
|
||||
n.PublicKey = parseGetString(r, "publicKey")
|
||||
n.ShortID = parseGetString(r, "shortId")
|
||||
n.SpiderX = parseGetString(r, "spiderX")
|
||||
n.Fingerprint = parseGetString(r, "fingerprint")
|
||||
}
|
||||
case "xtls":
|
||||
if x := parseAnyMap(parseGet(ss, "xtlsSettings")); x != nil {
|
||||
n.SNI = parseGetString(x, "serverName")
|
||||
n.ALPN = parseAnyStringList(parseGet(x, "alpn"))
|
||||
n.AllowInsecure = parseGetBool(x, "allowInsecure")
|
||||
}
|
||||
}
|
||||
|
||||
switch n.Network {
|
||||
case "ws":
|
||||
if ws := parseAnyMap(parseGet(ss, "wsSettings")); ws != nil {
|
||||
n.Path = parseGetString(ws, "path")
|
||||
if h := parseAnyMap(parseGet(ws, "headers")); h != nil {
|
||||
n.Host = parseClashHeader(h, "Host")
|
||||
}
|
||||
if n.Host == "" {
|
||||
n.Host = parseGetString(ws, "host")
|
||||
}
|
||||
}
|
||||
case "grpc":
|
||||
if g := parseAnyMap(parseGet(ss, "grpcSettings")); g != nil {
|
||||
n.ServiceName = parseGetString(g, "serviceName")
|
||||
n.Host = parseGetString(g, "authority")
|
||||
if parseGetBool(g, "multiMode") {
|
||||
parseSetExtra(n, "mode", "multi")
|
||||
}
|
||||
}
|
||||
case "h2", "http":
|
||||
if h := parseAnyMap(parseGet(ss, "httpSettings")); h != nil {
|
||||
n.Path = parseGetString(h, "path")
|
||||
if hosts := parseAnyStringList(parseGet(h, "host")); len(hosts) > 0 {
|
||||
n.Host = hosts[0]
|
||||
}
|
||||
}
|
||||
case "httpupgrade":
|
||||
if h := parseAnyMap(parseGet(ss, "httpupgradeSettings")); h != nil {
|
||||
n.Path = parseGetString(h, "path")
|
||||
n.Host = parseGetString(h, "host")
|
||||
}
|
||||
case "xhttp":
|
||||
if x := parseAnyMap(parseGet(ss, "xhttpSettings", "splithttpSettings")); x != nil {
|
||||
n.Path = parseGetString(x, "path")
|
||||
n.Host = parseGetString(x, "host")
|
||||
if m := parseGetString(x, "mode"); m != "" {
|
||||
parseSetExtra(n, "mode", m)
|
||||
}
|
||||
}
|
||||
case "kcp":
|
||||
if k := parseAnyMap(parseGet(ss, "kcpSettings")); k != nil {
|
||||
n.Seed = parseGetString(k, "seed")
|
||||
if hdr := parseAnyMap(parseGet(k, "header")); hdr != nil {
|
||||
n.HeaderType = parseGetString(hdr, "type")
|
||||
}
|
||||
}
|
||||
case "tcp":
|
||||
if t := parseAnyMap(parseGet(ss, "tcpSettings")); t != nil {
|
||||
if hdr := parseAnyMap(parseGet(t, "header")); hdr != nil {
|
||||
n.HeaderType = parseGetString(hdr, "type")
|
||||
if req := parseAnyMap(parseGet(hdr, "request")); req != nil {
|
||||
if hs := parseAnyMap(parseGet(req, "headers")); hs != nil {
|
||||
n.Host = parseClashHeader(hs, "Host")
|
||||
}
|
||||
if paths := parseAnyStringList(parseGet(req, "path")); len(paths) > 0 {
|
||||
n.Path = paths[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
case "hysteria":
|
||||
// Xray fork transport: the credential lives beside the QUIC settings.
|
||||
if h := parseAnyMap(parseGet(ss, "hysteriaSettings")); h != nil {
|
||||
if v := parseGetString(h, "auth", "auth_str"); v != "" && n.Password == "" {
|
||||
n.Password = v
|
||||
}
|
||||
if parseGetInt(h, "version") == 2 {
|
||||
n.Protocol = model.ProtoHysteria2
|
||||
}
|
||||
if v := parseGetString(h, "obfs"); v != "" {
|
||||
n.Obfs = v
|
||||
}
|
||||
if v := parseGetString(h, "obfsPassword", "obfs_password"); v != "" {
|
||||
n.ObfsPassword = v
|
||||
}
|
||||
n.UpMbps = parseGetInt(h, "up_mbps", "upMbps")
|
||||
n.DownMbps = parseGetInt(h, "down_mbps", "downMbps")
|
||||
}
|
||||
if tls := parseAnyMap(parseGet(ss, "tlsSettings")); tls != nil && n.SNI == "" {
|
||||
n.SNI = parseGetString(tls, "serverName")
|
||||
n.ALPN = parseAnyStringList(parseGet(tls, "alpn"))
|
||||
n.Fingerprint = parseGetString(tls, "fingerprint")
|
||||
}
|
||||
// A QUIC-only protocol has no stream network in the normalized model.
|
||||
n.Network = ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
package subfmt
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/omar/prizma/internal/model"
|
||||
)
|
||||
|
||||
// RenderOpts tunes a render. Both fields are optional.
|
||||
type RenderOpts struct {
|
||||
ProfileTitle string // optional, informational
|
||||
|
||||
// Template is the document skeleton to reuse (dns/routing/rules blocks).
|
||||
// When nil the renderer falls back to the first node's RawDoc, then to a
|
||||
// built-in minimal skeleton.
|
||||
Template map[string]any
|
||||
}
|
||||
|
||||
// Render serializes nodes in the requested format and returns the body plus the
|
||||
// Content-Type to answer with.
|
||||
//
|
||||
// Fidelity rule: when the target format matches the payload a node was parsed
|
||||
// from, that payload is emitted verbatim (only the display name is synced, so
|
||||
// group renaming still works) instead of being re-derived. Cross-format
|
||||
// rendering falls back to the normalized model.Node fields.
|
||||
func Render(nodes []model.Node, f Format, opts RenderOpts) ([]byte, string, error) {
|
||||
switch f {
|
||||
case FormatURIList:
|
||||
b, err := renderURIList(nodes)
|
||||
return b, ContentTypeFor(FormatURIList), err
|
||||
case FormatBase64, FormatUnknown, "":
|
||||
// Unknown clients get the v2rayN classic: every client understands it.
|
||||
b, err := renderBase64(nodes)
|
||||
return b, ContentTypeFor(FormatBase64), err
|
||||
case FormatClash:
|
||||
b, err := renderClash(nodes, opts)
|
||||
return b, ContentTypeFor(FormatClash), err
|
||||
case FormatSingBox:
|
||||
b, err := renderSingBox(nodes, opts)
|
||||
return b, ContentTypeFor(FormatSingBox), err
|
||||
case FormatXray:
|
||||
b, err := renderXray(nodes, opts)
|
||||
return b, ContentTypeFor(FormatXray), err
|
||||
default:
|
||||
return nil, "", fmt.Errorf("subfmt: cannot render format %q", f)
|
||||
}
|
||||
}
|
||||
|
||||
// rdDisplayName is the label a node renders under. Nameless nodes still need a
|
||||
// stable, unique handle because Clash proxy-groups and sing-box selectors
|
||||
// reference proxies by name.
|
||||
func rdDisplayName(n model.Node, index int) string {
|
||||
if s := strings.TrimSpace(n.Name); s != "" {
|
||||
return n.Name
|
||||
}
|
||||
if n.Server != "" {
|
||||
if n.Port > 0 {
|
||||
return n.Server + ":" + strconv.Itoa(n.Port)
|
||||
}
|
||||
return n.Server
|
||||
}
|
||||
if n.Protocol != "" {
|
||||
return n.Protocol + "-" + strconv.Itoa(index+1)
|
||||
}
|
||||
return "node-" + strconv.Itoa(index+1)
|
||||
}
|
||||
|
||||
// rdUniqueName keeps names distinct within one document. Duplicates are legal in
|
||||
// a URI list but break Clash and sing-box, where a name is a reference target.
|
||||
func rdUniqueName(name string, seen map[string]int) string {
|
||||
n := seen[name]
|
||||
seen[name] = n + 1
|
||||
if n == 0 {
|
||||
return name
|
||||
}
|
||||
for {
|
||||
cand := name + " " + strconv.Itoa(n+1)
|
||||
if seen[cand] == 0 {
|
||||
seen[cand] = 1
|
||||
return cand
|
||||
}
|
||||
n++
|
||||
}
|
||||
}
|
||||
|
||||
// rdPickTemplate chooses the document skeleton for a render: the caller's
|
||||
// template first, then the first surviving node that carries a matching
|
||||
// enclosing document. accept decides whether a document belongs to the target
|
||||
// format family — reusing a Clash root as a sing-box skeleton would produce
|
||||
// garbage. The result is a deep copy; RawDoc is shared with the fetch cache and
|
||||
// must never be mutated.
|
||||
func rdPickTemplate(opts RenderOpts, nodes []model.Node, accept func(map[string]any) bool) map[string]any {
|
||||
if accept(opts.Template) {
|
||||
return rdCopyMap(opts.Template)
|
||||
}
|
||||
for _, n := range nodes {
|
||||
if accept(n.RawDoc) {
|
||||
return rdCopyMap(n.RawDoc)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// rdDocIsClash recognizes a Clash/Mihomo document root.
|
||||
func rdDocIsClash(m map[string]any) bool {
|
||||
if len(m) == 0 {
|
||||
return false
|
||||
}
|
||||
if _, ok := m["proxies"]; ok {
|
||||
return true
|
||||
}
|
||||
if _, ok := m["proxy-groups"]; ok {
|
||||
return true
|
||||
}
|
||||
if _, ok := m["proxy-providers"]; ok {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// rdDocIsXray recognizes an Xray/V2Ray config: outbounds carrying "protocol",
|
||||
// plus the routing/inbounds vocabulary Xray uses.
|
||||
func rdDocIsXray(m map[string]any) bool {
|
||||
if len(m) == 0 {
|
||||
return false
|
||||
}
|
||||
obs := rdSlice(m["outbounds"])
|
||||
if len(obs) == 0 {
|
||||
return false
|
||||
}
|
||||
first := rdMap(obs[0])
|
||||
if _, ok := first["protocol"]; ok {
|
||||
return true
|
||||
}
|
||||
if _, ok := first["type"]; ok {
|
||||
return false // sing-box
|
||||
}
|
||||
_, hasRouting := m["routing"]
|
||||
return hasRouting
|
||||
}
|
||||
|
||||
// rdDocIsSingBox recognizes a sing-box config: outbounds carrying "type", or the
|
||||
// route/experimental blocks only sing-box has.
|
||||
func rdDocIsSingBox(m map[string]any) bool {
|
||||
if len(m) == 0 {
|
||||
return false
|
||||
}
|
||||
obs := rdSlice(m["outbounds"])
|
||||
if len(obs) > 0 {
|
||||
first := rdMap(obs[0])
|
||||
if _, ok := first["type"]; ok {
|
||||
return true
|
||||
}
|
||||
if _, ok := first["protocol"]; ok {
|
||||
return false // xray
|
||||
}
|
||||
}
|
||||
if _, ok := m["route"]; ok {
|
||||
return true
|
||||
}
|
||||
if _, ok := m["experimental"]; ok {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// rdRewriteRefs repoints the name lists of aggregate entries — Clash
|
||||
// proxy-groups, sing-box selector/urltest outbounds — at the nodes that
|
||||
// actually survived. Without this a reused template still lists the upstream's
|
||||
// original proxies and the config is rejected by the client.
|
||||
//
|
||||
// entries are the candidate maps, listKey the key holding the name list, isAgg
|
||||
// decides which entries are aggregates, names are the surviving nodes in render
|
||||
// order, and allowed holds references that are not node names (other aggregates,
|
||||
// DIRECT/REJECT, a retained "direct" outbound) and must be kept as they are.
|
||||
//
|
||||
// Returns true when at least one aggregate now points at nodes.
|
||||
func rdRewriteRefs(entries []any, listKey string, isAgg func(map[string]any) bool, names []string, allowed map[string]bool) bool {
|
||||
nameSet := make(map[string]bool, len(names))
|
||||
for _, n := range names {
|
||||
nameSet[n] = true
|
||||
}
|
||||
referenced := make(map[string]bool, len(names))
|
||||
|
||||
var host map[string]any // first aggregate that referenced proxies at all
|
||||
consumed := false
|
||||
|
||||
for _, e := range entries {
|
||||
m := rdMap(e)
|
||||
if m == nil || !isAgg(m) {
|
||||
continue
|
||||
}
|
||||
list, ok := m[listKey]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
var kept []any
|
||||
nodeRefs, hadNodeRefs := 0, false
|
||||
for _, item := range rdSlice(list) {
|
||||
s := rdStr(item)
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
if allowed[s] || allowed[strings.ToUpper(s)] {
|
||||
kept = append(kept, s)
|
||||
continue
|
||||
}
|
||||
// Anything not an aggregate or a builtin was a proxy name.
|
||||
hadNodeRefs = true
|
||||
if nameSet[s] {
|
||||
kept = append(kept, s)
|
||||
referenced[s] = true
|
||||
nodeRefs++
|
||||
}
|
||||
}
|
||||
if hadNodeRefs && nodeRefs == 0 {
|
||||
// Every proxy this aggregate pointed at is gone; an empty select
|
||||
// group is a broken config, so it adopts the whole node list.
|
||||
for _, n := range names {
|
||||
kept = append(kept, n)
|
||||
referenced[n] = true
|
||||
}
|
||||
nodeRefs = len(names)
|
||||
}
|
||||
if kept == nil {
|
||||
kept = []any{}
|
||||
}
|
||||
m[listKey] = kept
|
||||
if hadNodeRefs {
|
||||
consumed = true
|
||||
if host == nil {
|
||||
host = m
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Nodes nobody references would be dead weight in the document: hand them
|
||||
// to the aggregate that owns proxy references.
|
||||
if host != nil {
|
||||
extra := rdSlice(host[listKey])
|
||||
added := false
|
||||
for _, n := range names {
|
||||
if !referenced[n] {
|
||||
extra = append(extra, n)
|
||||
added = true
|
||||
}
|
||||
}
|
||||
if added {
|
||||
host[listKey] = extra
|
||||
}
|
||||
}
|
||||
return consumed
|
||||
}
|
||||
|
||||
// rdMap coerces a decoded value to a string-keyed map, tolerating the
|
||||
// map[any]any some YAML decoders produce.
|
||||
func rdMap(v any) map[string]any {
|
||||
switch m := v.(type) {
|
||||
case map[string]any:
|
||||
return m
|
||||
case map[any]any:
|
||||
out := make(map[string]any, len(m))
|
||||
for k, val := range m {
|
||||
out[fmt.Sprint(k)] = val
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func rdSlice(v any) []any {
|
||||
if s, ok := v.([]any); ok {
|
||||
return s
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func rdStr(v any) string {
|
||||
switch s := v.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
return s
|
||||
case fmt.Stringer:
|
||||
return s.String()
|
||||
default:
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
}
|
||||
|
||||
// rdCopy deep-copies a decoded document and normalizes map[any]any to
|
||||
// map[string]any on the way, so the result is always JSON-marshalable.
|
||||
func rdCopy(v any) any {
|
||||
switch t := v.(type) {
|
||||
case map[string]any:
|
||||
out := make(map[string]any, len(t))
|
||||
for k, val := range t {
|
||||
out[k] = rdCopy(val)
|
||||
}
|
||||
return out
|
||||
case map[any]any:
|
||||
out := make(map[string]any, len(t))
|
||||
for k, val := range t {
|
||||
out[fmt.Sprint(k)] = rdCopy(val)
|
||||
}
|
||||
return out
|
||||
case []any:
|
||||
out := make([]any, len(t))
|
||||
for i, val := range t {
|
||||
out[i] = rdCopy(val)
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
func rdCopyMap(m map[string]any) map[string]any {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
return rdCopy(m).(map[string]any)
|
||||
}
|
||||
|
||||
// rdJSON marshals compactly without HTML escaping: subscription names are full
|
||||
// of characters json.Marshal would turn into < escapes for no reason.
|
||||
func rdJSON(v any) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
enc := json.NewEncoder(&buf)
|
||||
enc.SetEscapeHTML(false)
|
||||
if err := enc.Encode(v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return bytes.TrimRight(buf.Bytes(), "\n"), nil
|
||||
}
|
||||
|
||||
// rdSet fills dst[key] when value is non-empty; keeps generated documents free
|
||||
// of empty strings clients would misread as "configured, but blank".
|
||||
func rdSet(dst map[string]any, key, value string) {
|
||||
if value != "" {
|
||||
dst[key] = value
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
package subfmt
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/omar/prizma/internal/model"
|
||||
)
|
||||
|
||||
// renderClash writes a Clash / Clash.Meta (Mihomo) YAML document.
|
||||
//
|
||||
// The document skeleton comes from the caller's template or from the first
|
||||
// surviving node's RawDoc, so the upstream's dns, rules and tun blocks survive
|
||||
// the merge. Only the proxy list and the name references inside proxy-groups
|
||||
// are rewritten.
|
||||
func renderClash(nodes []model.Node, opts RenderOpts) ([]byte, error) {
|
||||
doc := rdPickTemplate(opts, nodes, rdDocIsClash)
|
||||
if doc == nil {
|
||||
doc = rdDefaultClashDoc()
|
||||
}
|
||||
|
||||
seen := make(map[string]int, len(nodes))
|
||||
proxies := make([]any, 0, len(nodes))
|
||||
names := make([]string, 0, len(nodes))
|
||||
for i, n := range nodes {
|
||||
p := rdClashProxy(n)
|
||||
if p == nil {
|
||||
continue // not expressible as a Clash proxy; skipping beats a broken file
|
||||
}
|
||||
name := rdUniqueName(rdDisplayName(n, i), seen)
|
||||
p["name"] = name
|
||||
proxies = append(proxies, p)
|
||||
names = append(names, name)
|
||||
}
|
||||
doc["proxies"] = proxies
|
||||
rdFixClashGroups(doc, names)
|
||||
|
||||
var buf bytes.Buffer
|
||||
enc := yaml.NewEncoder(&buf)
|
||||
enc.SetIndent(2) // the indentation every published Clash config uses
|
||||
if err := enc.Encode(doc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := enc.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// rdClashBuiltins are the policies a proxy-group may name that are not proxies.
|
||||
var rdClashBuiltins = map[string]bool{
|
||||
"DIRECT": true, "REJECT": true, "REJECT-DROP": true,
|
||||
"PASS": true, "GLOBAL": true, "COMPATIBLE": true,
|
||||
}
|
||||
|
||||
// rdFixClashGroups repoints proxy-groups at the surviving proxies. A reused
|
||||
// template still lists the upstream's original proxy names; leaving them in
|
||||
// place makes every Clash client reject the config outright.
|
||||
func rdFixClashGroups(doc map[string]any, names []string) {
|
||||
groups := rdSlice(doc["proxy-groups"])
|
||||
|
||||
allowed := make(map[string]bool, len(rdClashBuiltins)+len(groups))
|
||||
for k, v := range rdClashBuiltins {
|
||||
allowed[k] = v
|
||||
}
|
||||
for _, g := range groups {
|
||||
if m := rdMap(g); m != nil {
|
||||
if nm := rdStr(m["name"]); nm != "" {
|
||||
allowed[nm] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isAgg := func(m map[string]any) bool {
|
||||
_, ok := m["proxies"]
|
||||
return ok
|
||||
}
|
||||
if len(groups) > 0 && rdRewriteRefs(groups, "proxies", isAgg, names, allowed) {
|
||||
return
|
||||
}
|
||||
if len(names) == 0 {
|
||||
if _, ok := doc["proxy-groups"]; !ok {
|
||||
doc["proxy-groups"] = []any{}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Nothing in the template selects proxies: give the document a usable
|
||||
// selector plus a latency test, and a MATCH rule when it has no rules.
|
||||
sel := make([]any, 0, len(names)+1)
|
||||
sel = append(sel, "AUTO")
|
||||
auto := make([]any, 0, len(names))
|
||||
for _, n := range names {
|
||||
sel = append(sel, n)
|
||||
auto = append(auto, n)
|
||||
}
|
||||
doc["proxy-groups"] = append([]any{
|
||||
map[string]any{"name": "PROXY", "type": "select", "proxies": sel},
|
||||
map[string]any{
|
||||
"name": "AUTO",
|
||||
"type": "url-test",
|
||||
"url": "http://www.gstatic.com/generate_204",
|
||||
"interval": 300,
|
||||
"proxies": auto,
|
||||
},
|
||||
}, groups...)
|
||||
if len(rdSlice(doc["rules"])) == 0 {
|
||||
doc["rules"] = []any{"MATCH,PROXY"}
|
||||
}
|
||||
}
|
||||
|
||||
func rdDefaultClashDoc() map[string]any {
|
||||
return map[string]any{
|
||||
"mixed-port": 7890,
|
||||
"allow-lan": false,
|
||||
"mode": "rule",
|
||||
"log-level": "info",
|
||||
"dns": map[string]any{
|
||||
"enable": true,
|
||||
"enhanced-mode": "fake-ip",
|
||||
"fake-ip-range": "198.18.0.1/16",
|
||||
"nameserver": []any{"1.1.1.1", "8.8.8.8"},
|
||||
"fallback": []any{"tls://1.1.1.1:853"},
|
||||
"default-nameserver": []any{"1.1.1.1", "8.8.8.8"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// rdClashProxy returns the node as a Clash proxy map: its own payload when it
|
||||
// was born from Clash, a derived one otherwise.
|
||||
func rdClashProxy(n model.Node) map[string]any {
|
||||
if len(n.RawClash) > 0 {
|
||||
p := rdCopyMap(n.RawClash)
|
||||
if rdStr(p["type"]) == "" {
|
||||
rdSet(p, "type", rdClashType(n.Protocol))
|
||||
}
|
||||
return p
|
||||
}
|
||||
return rdDeriveClashProxy(n)
|
||||
}
|
||||
|
||||
// rdClashType maps the model protocol vocabulary onto Clash type names.
|
||||
func rdClashType(proto string) string {
|
||||
switch rdProto(proto) {
|
||||
case model.ProtoShadowsock:
|
||||
return "ss"
|
||||
case model.ProtoSSR:
|
||||
return "ssr"
|
||||
case model.ProtoSOCKS:
|
||||
return "socks5"
|
||||
default:
|
||||
return rdProto(proto)
|
||||
}
|
||||
}
|
||||
|
||||
func rdDeriveClashProxy(n model.Node) map[string]any {
|
||||
if n.Server == "" || n.Port == 0 {
|
||||
return nil
|
||||
}
|
||||
p := map[string]any{
|
||||
"type": rdClashType(n.Protocol),
|
||||
"server": n.Server,
|
||||
"port": n.Port,
|
||||
}
|
||||
|
||||
switch rdProto(n.Protocol) {
|
||||
case model.ProtoVLESS:
|
||||
p["uuid"] = n.UUID
|
||||
p["udp"] = true
|
||||
rdSet(p, "flow", n.Flow)
|
||||
rdSet(p, "packet-encoding", "xudp")
|
||||
rdClashTransport(p, n)
|
||||
rdClashTLS(p, n, n.Security == "tls" || n.Security == "reality" || n.Security == "xtls")
|
||||
|
||||
case model.ProtoVMess:
|
||||
p["uuid"] = n.UUID
|
||||
p["alterId"] = n.AlterID
|
||||
p["cipher"] = rdFirst(n.Method, "auto")
|
||||
p["udp"] = true
|
||||
rdClashTransport(p, n)
|
||||
rdClashTLS(p, n, n.Security == "tls" || n.Security == "reality")
|
||||
|
||||
case model.ProtoTrojan:
|
||||
p["password"] = n.Password
|
||||
p["udp"] = true
|
||||
rdSet(p, "flow", n.Flow)
|
||||
rdClashTransport(p, n)
|
||||
rdClashTLS(p, n, true)
|
||||
|
||||
case model.ProtoShadowsock:
|
||||
p["cipher"] = rdFirst(n.Method, "aes-128-gcm")
|
||||
p["password"] = n.Password
|
||||
p["udp"] = true
|
||||
rdSet(p, "plugin", rdExtra(n, "plugin"))
|
||||
|
||||
case model.ProtoSSR:
|
||||
p["cipher"] = rdFirst(n.Method, "none")
|
||||
p["password"] = n.Password
|
||||
p["protocol"] = rdFirst(rdExtra(n, "protocol"), "origin")
|
||||
p["obfs"] = rdFirst(n.Obfs, "plain")
|
||||
rdSet(p, "obfs-param", rdExtra(n, "obfs_param", "obfs-param", "obfsparam"))
|
||||
rdSet(p, "protocol-param", rdExtra(n, "protocol_param", "protocol-param", "protoparam"))
|
||||
p["udp"] = true
|
||||
|
||||
case model.ProtoHysteria2:
|
||||
p["password"] = rdFirst(n.Password, n.UUID)
|
||||
rdSet(p, "obfs", n.Obfs)
|
||||
rdSet(p, "obfs-password", n.ObfsPassword)
|
||||
if n.UpMbps > 0 {
|
||||
p["up"] = strconv.Itoa(n.UpMbps) + " Mbps"
|
||||
}
|
||||
if n.DownMbps > 0 {
|
||||
p["down"] = strconv.Itoa(n.DownMbps) + " Mbps"
|
||||
}
|
||||
rdClashTLS(p, n, true)
|
||||
|
||||
case model.ProtoHysteria:
|
||||
p["auth-str"] = rdFirst(n.Password, n.UUID)
|
||||
rdSet(p, "obfs", n.Obfs)
|
||||
rdSet(p, "protocol", rdFirst(rdExtra(n, "protocol"), "udp"))
|
||||
if n.UpMbps > 0 {
|
||||
p["up"] = n.UpMbps
|
||||
}
|
||||
if n.DownMbps > 0 {
|
||||
p["down"] = n.DownMbps
|
||||
}
|
||||
rdClashTLS(p, n, true)
|
||||
|
||||
case model.ProtoTUIC:
|
||||
rdSet(p, "uuid", n.UUID)
|
||||
rdSet(p, "password", n.Password)
|
||||
rdSet(p, "congestion-controller", n.Congestion)
|
||||
rdSet(p, "udp-relay-mode", rdExtra(n, "udp_relay_mode", "udp-relay-mode"))
|
||||
rdClashTLS(p, n, true)
|
||||
|
||||
case model.ProtoAnyTLS:
|
||||
p["password"] = n.Password
|
||||
p["udp"] = true
|
||||
rdClashTLS(p, n, true)
|
||||
|
||||
case model.ProtoSOCKS, model.ProtoHTTP:
|
||||
rdSet(p, "username", rdExtra(n, "username", "user"))
|
||||
rdSet(p, "password", n.Password)
|
||||
p["udp"] = rdProto(n.Protocol) == model.ProtoSOCKS
|
||||
if n.Security == "tls" || rdExtraBool(n, "tls") {
|
||||
p["tls"] = true
|
||||
rdSet(p, "sni", n.SNI)
|
||||
if n.AllowInsecure {
|
||||
p["skip-cert-verify"] = true
|
||||
}
|
||||
}
|
||||
|
||||
case model.ProtoWireGuard:
|
||||
rdSet(p, "private-key", rdFirst(rdExtra(n, "private_key", "privateKey", "private-key", "secretKey"), n.Password))
|
||||
rdSet(p, "public-key", rdFirst(rdExtra(n, "public_key", "publicKey", "public-key"), n.PublicKey))
|
||||
rdSet(p, "pre-shared-key", rdExtra(n, "pre_shared_key", "preSharedKey", "pre-shared-key"))
|
||||
rdSet(p, "ip", rdExtra(n, "address", "ip", "local_address"))
|
||||
rdSet(p, "mtu", rdExtra(n, "mtu"))
|
||||
p["udp"] = true
|
||||
|
||||
case model.ProtoSSH:
|
||||
rdSet(p, "username", rdExtra(n, "username", "user"))
|
||||
rdSet(p, "password", n.Password)
|
||||
|
||||
default:
|
||||
return nil // Clash cannot express this protocol
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// rdClashTLS adds the security block shared by most Clash proxy types.
|
||||
func rdClashTLS(p map[string]any, n model.Node, tls bool) {
|
||||
if tls {
|
||||
p["tls"] = true
|
||||
}
|
||||
sni := rdFirst(n.SNI, n.Host)
|
||||
switch p["type"] {
|
||||
case "vless", "vmess":
|
||||
rdSet(p, "servername", sni)
|
||||
default:
|
||||
rdSet(p, "sni", sni)
|
||||
}
|
||||
rdSet(p, "client-fingerprint", n.Fingerprint)
|
||||
if len(n.ALPN) > 0 {
|
||||
alpn := make([]any, len(n.ALPN))
|
||||
for i, a := range n.ALPN {
|
||||
alpn[i] = a
|
||||
}
|
||||
p["alpn"] = alpn
|
||||
}
|
||||
if n.AllowInsecure {
|
||||
p["skip-cert-verify"] = true
|
||||
}
|
||||
if n.Security == "reality" || n.PublicKey != "" {
|
||||
p["reality-opts"] = map[string]any{
|
||||
"public-key": n.PublicKey,
|
||||
"short-id": n.ShortID,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// rdClashTransport adds the network block for the stream-based protocols.
|
||||
func rdClashTransport(p map[string]any, n model.Node) {
|
||||
network := rdFirst(n.Network, "tcp")
|
||||
p["network"] = network
|
||||
switch network {
|
||||
case "ws":
|
||||
opts := map[string]any{}
|
||||
rdSet(opts, "path", rdFirst(n.Path, "/"))
|
||||
if n.Host != "" {
|
||||
opts["headers"] = map[string]any{"Host": n.Host}
|
||||
}
|
||||
p["ws-opts"] = opts
|
||||
case "grpc":
|
||||
p["grpc-opts"] = map[string]any{
|
||||
"grpc-service-name": rdFirst(n.ServiceName, strings.TrimPrefix(n.Path, "/")),
|
||||
}
|
||||
case "http", "h2":
|
||||
opts := map[string]any{}
|
||||
if n.Path != "" {
|
||||
opts["path"] = []any{n.Path}
|
||||
}
|
||||
if n.Host != "" {
|
||||
opts["host"] = []any{n.Host}
|
||||
}
|
||||
p["h2-opts"] = opts
|
||||
case "httpupgrade":
|
||||
opts := map[string]any{}
|
||||
rdSet(opts, "path", n.Path)
|
||||
rdSet(opts, "host", n.Host)
|
||||
p["ws-opts"] = opts
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
package subfmt
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/omar/prizma/internal/model"
|
||||
)
|
||||
|
||||
// rdSingBoxSpecials are the outbound types that are policy, not servers: they
|
||||
// are kept from the template while the proxy outbounds are replaced.
|
||||
var rdSingBoxSpecials = map[string]bool{
|
||||
"selector": true, "urltest": true, "direct": true, "block": true, "dns": true,
|
||||
}
|
||||
|
||||
// renderSingBox writes a sing-box JSON config.
|
||||
//
|
||||
// The template's log/dns/inbounds/route blocks are preserved; its proxy
|
||||
// outbounds are replaced by the surviving nodes, and every selector/urltest is
|
||||
// repointed at the new tags — a selector naming a missing outbound makes
|
||||
// sing-box refuse to start.
|
||||
func renderSingBox(nodes []model.Node, opts RenderOpts) ([]byte, error) {
|
||||
doc := rdPickTemplate(opts, nodes, rdDocIsSingBox)
|
||||
if doc == nil {
|
||||
doc = rdDefaultSingBoxDoc()
|
||||
}
|
||||
|
||||
var specials []any
|
||||
specialTags := make(map[string]bool)
|
||||
for _, o := range rdSlice(doc["outbounds"]) {
|
||||
m := rdMap(o)
|
||||
if m == nil || !rdSingBoxSpecials[rdStr(m["type"])] {
|
||||
continue
|
||||
}
|
||||
specials = append(specials, m)
|
||||
if t := rdStr(m["tag"]); t != "" {
|
||||
specialTags[t] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Reserve the policy tags so a node called "direct" cannot shadow one.
|
||||
seen := make(map[string]int, len(nodes)+len(specialTags))
|
||||
for t := range specialTags {
|
||||
seen[t] = 1
|
||||
}
|
||||
|
||||
outbounds := make([]any, 0, len(nodes)+len(specials))
|
||||
names := make([]string, 0, len(nodes))
|
||||
for i, n := range nodes {
|
||||
o := rdSingBoxOutbound(n)
|
||||
if o == nil {
|
||||
continue // sing-box has no outbound for this protocol
|
||||
}
|
||||
tag := rdUniqueName(rdDisplayName(n, i), seen)
|
||||
o["tag"] = tag
|
||||
outbounds = append(outbounds, o)
|
||||
names = append(names, tag)
|
||||
}
|
||||
|
||||
allowed := make(map[string]bool, len(specialTags))
|
||||
for t := range specialTags {
|
||||
allowed[t] = true
|
||||
}
|
||||
isAgg := func(m map[string]any) bool {
|
||||
t := rdStr(m["type"])
|
||||
return t == "selector" || t == "urltest"
|
||||
}
|
||||
if !rdRewriteRefs(specials, "outbounds", isAgg, names, allowed) && len(names) > 0 {
|
||||
sel := make([]any, 0, len(names)+1)
|
||||
sel = append(sel, "AUTO")
|
||||
auto := make([]any, 0, len(names))
|
||||
for _, n := range names {
|
||||
sel = append(sel, n)
|
||||
auto = append(auto, n)
|
||||
}
|
||||
specials = append([]any{
|
||||
map[string]any{"type": "selector", "tag": "PROXY", "outbounds": sel, "default": "AUTO"},
|
||||
map[string]any{
|
||||
"type": "urltest",
|
||||
"tag": "AUTO",
|
||||
"outbounds": auto,
|
||||
"url": "http://www.gstatic.com/generate_204",
|
||||
"interval": "5m",
|
||||
},
|
||||
}, specials...)
|
||||
specialTags["PROXY"] = true
|
||||
specialTags["AUTO"] = true
|
||||
}
|
||||
if !specialTags["direct"] {
|
||||
specials = append(specials, map[string]any{"type": "direct", "tag": "direct"})
|
||||
specialTags["direct"] = true
|
||||
}
|
||||
|
||||
doc["outbounds"] = append(outbounds, specials...)
|
||||
rdFixSingBoxFinal(doc, specials, names)
|
||||
|
||||
return rdJSON(doc)
|
||||
}
|
||||
|
||||
// rdFixSingBoxFinal makes sure route.final still names an outbound that exists;
|
||||
// a dangling final is a startup error, not a warning.
|
||||
func rdFixSingBoxFinal(doc map[string]any, specials []any, names []string) {
|
||||
route := rdMap(doc["route"])
|
||||
if route == nil {
|
||||
route = map[string]any{"auto_detect_interface": true}
|
||||
doc["route"] = route
|
||||
}
|
||||
valid := make(map[string]bool, len(names)+len(specials))
|
||||
for _, n := range names {
|
||||
valid[n] = true
|
||||
}
|
||||
firstSelector := ""
|
||||
for _, s := range specials {
|
||||
m := rdMap(s)
|
||||
tag := rdStr(m["tag"])
|
||||
if tag == "" {
|
||||
continue
|
||||
}
|
||||
valid[tag] = true
|
||||
if firstSelector == "" {
|
||||
switch rdStr(m["type"]) {
|
||||
case "selector", "urltest":
|
||||
firstSelector = tag
|
||||
}
|
||||
}
|
||||
}
|
||||
if f := rdStr(route["final"]); f != "" && valid[f] {
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case firstSelector != "":
|
||||
route["final"] = firstSelector
|
||||
case len(names) > 0:
|
||||
route["final"] = names[0]
|
||||
default:
|
||||
route["final"] = "direct"
|
||||
}
|
||||
}
|
||||
|
||||
func rdDefaultSingBoxDoc() map[string]any {
|
||||
return map[string]any{
|
||||
"log": map[string]any{"level": "info", "timestamp": true},
|
||||
"dns": map[string]any{
|
||||
"servers": []any{
|
||||
map[string]any{"tag": "remote", "address": "https://1.1.1.1/dns-query", "detour": "PROXY"},
|
||||
map[string]any{"tag": "local", "address": "local", "detour": "direct"},
|
||||
},
|
||||
"final": "remote",
|
||||
"strategy": "prefer_ipv4",
|
||||
},
|
||||
"inbounds": []any{
|
||||
map[string]any{
|
||||
"type": "mixed",
|
||||
"tag": "mixed-in",
|
||||
"listen": "127.0.0.1",
|
||||
"listen_port": 2080,
|
||||
},
|
||||
},
|
||||
"route": map[string]any{"auto_detect_interface": true},
|
||||
}
|
||||
}
|
||||
|
||||
// rdSingBoxOutbound returns the node's own sing-box outbound when it has one,
|
||||
// and derives one from the normalized fields otherwise.
|
||||
func rdSingBoxOutbound(n model.Node) map[string]any {
|
||||
if len(n.RawSingBox) > 0 {
|
||||
o := rdCopyMap(n.RawSingBox)
|
||||
if rdStr(o["type"]) == "" {
|
||||
rdSet(o, "type", rdSingBoxType(n.Protocol))
|
||||
}
|
||||
return o
|
||||
}
|
||||
return rdDeriveSingBoxOutbound(n)
|
||||
}
|
||||
|
||||
func rdSingBoxType(proto string) string {
|
||||
switch rdProto(proto) {
|
||||
case model.ProtoShadowsock:
|
||||
return "shadowsocks"
|
||||
case model.ProtoSSR:
|
||||
return "shadowsocksr"
|
||||
default:
|
||||
return rdProto(proto)
|
||||
}
|
||||
}
|
||||
|
||||
func rdDeriveSingBoxOutbound(n model.Node) map[string]any {
|
||||
if n.Server == "" || n.Port == 0 {
|
||||
return nil
|
||||
}
|
||||
o := map[string]any{
|
||||
"type": rdSingBoxType(n.Protocol),
|
||||
"server": n.Server,
|
||||
"server_port": n.Port,
|
||||
}
|
||||
|
||||
switch rdProto(n.Protocol) {
|
||||
case model.ProtoVLESS:
|
||||
o["uuid"] = n.UUID
|
||||
rdSet(o, "flow", n.Flow)
|
||||
o["packet_encoding"] = "xudp"
|
||||
rdSingBoxTransport(o, n)
|
||||
rdSingBoxTLS(o, n, n.Security == "tls" || n.Security == "reality" || n.Security == "xtls")
|
||||
|
||||
case model.ProtoVMess:
|
||||
o["uuid"] = n.UUID
|
||||
o["security"] = rdFirst(n.Method, "auto")
|
||||
if n.AlterID > 0 {
|
||||
o["alter_id"] = n.AlterID
|
||||
}
|
||||
rdSingBoxTransport(o, n)
|
||||
rdSingBoxTLS(o, n, n.Security == "tls" || n.Security == "reality")
|
||||
|
||||
case model.ProtoTrojan:
|
||||
o["password"] = n.Password
|
||||
rdSingBoxTransport(o, n)
|
||||
rdSingBoxTLS(o, n, true)
|
||||
|
||||
case model.ProtoShadowsock:
|
||||
o["method"] = rdFirst(n.Method, "aes-128-gcm")
|
||||
o["password"] = n.Password
|
||||
rdSet(o, "plugin", rdExtra(n, "plugin"))
|
||||
rdSet(o, "plugin_opts", rdExtra(n, "plugin_opts", "plugin-opts"))
|
||||
|
||||
case model.ProtoSSR:
|
||||
o["method"] = rdFirst(n.Method, "none")
|
||||
o["password"] = n.Password
|
||||
o["obfs"] = rdFirst(n.Obfs, "plain")
|
||||
o["protocol"] = rdFirst(rdExtra(n, "protocol"), "origin")
|
||||
rdSet(o, "obfs_param", rdExtra(n, "obfs_param", "obfs-param", "obfsparam"))
|
||||
rdSet(o, "protocol_param", rdExtra(n, "protocol_param", "protocol-param", "protoparam"))
|
||||
|
||||
case model.ProtoHysteria2:
|
||||
o["password"] = rdFirst(n.Password, n.UUID)
|
||||
if n.Obfs != "" {
|
||||
o["obfs"] = map[string]any{"type": n.Obfs, "password": n.ObfsPassword}
|
||||
}
|
||||
if n.UpMbps > 0 {
|
||||
o["up_mbps"] = n.UpMbps
|
||||
}
|
||||
if n.DownMbps > 0 {
|
||||
o["down_mbps"] = n.DownMbps
|
||||
}
|
||||
rdSingBoxTLS(o, n, true)
|
||||
|
||||
case model.ProtoHysteria:
|
||||
o["auth_str"] = rdFirst(n.Password, n.UUID)
|
||||
rdSet(o, "obfs", n.Obfs)
|
||||
if n.UpMbps > 0 {
|
||||
o["up_mbps"] = n.UpMbps
|
||||
}
|
||||
if n.DownMbps > 0 {
|
||||
o["down_mbps"] = n.DownMbps
|
||||
}
|
||||
rdSingBoxTLS(o, n, true)
|
||||
|
||||
case model.ProtoTUIC:
|
||||
rdSet(o, "uuid", n.UUID)
|
||||
rdSet(o, "password", n.Password)
|
||||
rdSet(o, "congestion_control", n.Congestion)
|
||||
rdSet(o, "udp_relay_mode", rdExtra(n, "udp_relay_mode", "udp-relay-mode"))
|
||||
rdSingBoxTLS(o, n, true)
|
||||
|
||||
case model.ProtoAnyTLS:
|
||||
o["password"] = n.Password
|
||||
rdSingBoxTLS(o, n, true)
|
||||
|
||||
case model.ProtoSOCKS:
|
||||
o["version"] = "5"
|
||||
rdSet(o, "username", rdExtra(n, "username", "user"))
|
||||
rdSet(o, "password", n.Password)
|
||||
|
||||
case model.ProtoHTTP:
|
||||
rdSet(o, "username", rdExtra(n, "username", "user"))
|
||||
rdSet(o, "password", n.Password)
|
||||
if n.Security == "tls" || rdExtraBool(n, "tls") {
|
||||
rdSingBoxTLS(o, n, true)
|
||||
}
|
||||
|
||||
case model.ProtoSSH:
|
||||
rdSet(o, "user", rdExtra(n, "username", "user"))
|
||||
rdSet(o, "password", n.Password)
|
||||
|
||||
case model.ProtoWireGuard:
|
||||
rdSet(o, "private_key", rdFirst(rdExtra(n, "private_key", "privateKey", "private-key", "secretKey"), n.Password))
|
||||
rdSet(o, "peer_public_key", rdFirst(rdExtra(n, "public_key", "publicKey", "public-key"), n.PublicKey))
|
||||
rdSet(o, "pre_shared_key", rdExtra(n, "pre_shared_key", "preSharedKey", "pre-shared-key"))
|
||||
if addr := rdExtra(n, "address", "local_address", "ip"); addr != "" {
|
||||
parts := strings.Split(addr, ",")
|
||||
list := make([]any, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
list = append(list, p)
|
||||
}
|
||||
}
|
||||
o["local_address"] = list
|
||||
}
|
||||
|
||||
default:
|
||||
return nil // no sing-box outbound expresses this protocol
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
func rdSingBoxTLS(o map[string]any, n model.Node, enabled bool) {
|
||||
if !enabled {
|
||||
return
|
||||
}
|
||||
tls := map[string]any{"enabled": true}
|
||||
rdSet(tls, "server_name", rdFirst(n.SNI, n.Host, n.Server))
|
||||
if n.AllowInsecure {
|
||||
tls["insecure"] = true
|
||||
}
|
||||
if len(n.ALPN) > 0 {
|
||||
alpn := make([]any, len(n.ALPN))
|
||||
for i, a := range n.ALPN {
|
||||
alpn[i] = a
|
||||
}
|
||||
tls["alpn"] = alpn
|
||||
}
|
||||
if n.Fingerprint != "" {
|
||||
tls["utls"] = map[string]any{"enabled": true, "fingerprint": n.Fingerprint}
|
||||
}
|
||||
if n.Security == "reality" || n.PublicKey != "" {
|
||||
reality := map[string]any{"enabled": true, "public_key": n.PublicKey}
|
||||
rdSet(reality, "short_id", n.ShortID)
|
||||
tls["reality"] = reality
|
||||
}
|
||||
o["tls"] = tls
|
||||
}
|
||||
|
||||
func rdSingBoxTransport(o map[string]any, n model.Node) {
|
||||
switch n.Network {
|
||||
case "", "tcp", "raw":
|
||||
return // sing-box omits the transport block for plain TCP
|
||||
case "ws":
|
||||
t := map[string]any{"type": "ws"}
|
||||
rdSet(t, "path", rdFirst(n.Path, "/"))
|
||||
if n.Host != "" {
|
||||
t["headers"] = map[string]any{"Host": n.Host}
|
||||
}
|
||||
o["transport"] = t
|
||||
case "grpc":
|
||||
o["transport"] = map[string]any{
|
||||
"type": "grpc",
|
||||
"service_name": rdFirst(n.ServiceName, strings.TrimPrefix(n.Path, "/")),
|
||||
}
|
||||
case "http", "h2":
|
||||
t := map[string]any{"type": "http"}
|
||||
rdSet(t, "path", n.Path)
|
||||
if n.Host != "" {
|
||||
t["host"] = []any{n.Host}
|
||||
}
|
||||
o["transport"] = t
|
||||
case "httpupgrade":
|
||||
t := map[string]any{"type": "httpupgrade"}
|
||||
rdSet(t, "path", n.Path)
|
||||
rdSet(t, "host", n.Host)
|
||||
o["transport"] = t
|
||||
case "quic":
|
||||
o["transport"] = map[string]any{"type": "quic"}
|
||||
default:
|
||||
// Unknown transport (xhttp, kcp): sing-box would reject the block, and
|
||||
// dropping it still yields a usable TCP outbound.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,566 @@
|
||||
package subfmt
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/omar/prizma/internal/model"
|
||||
)
|
||||
|
||||
// renderURIList emits one proxy URI per line, no trailing newline — byte for
|
||||
// byte what an upstream URI-list subscription looks like.
|
||||
func renderURIList(nodes []model.Node) ([]byte, error) {
|
||||
return []byte(strings.Join(rdURILines(nodes), "\n")), nil
|
||||
}
|
||||
|
||||
// renderBase64 is the v2rayN classic: standard base64 of the URI list, padded,
|
||||
// on a single line.
|
||||
func renderBase64(nodes []model.Node) ([]byte, error) {
|
||||
list, err := renderURIList(nodes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []byte(base64.StdEncoding.EncodeToString(list)), nil
|
||||
}
|
||||
|
||||
func rdURILines(nodes []model.Node) []string {
|
||||
out := make([]string, 0, len(nodes))
|
||||
for i, n := range nodes {
|
||||
u := rdNodeURI(n, i)
|
||||
if u == "" {
|
||||
continue // nothing representable as a URI; never fatal
|
||||
}
|
||||
out = append(out, u)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// rdNodeURI returns the node's own URI when it has one — the fidelity rule — and
|
||||
// derives one from the normalized fields otherwise. A node whose name was
|
||||
// rewritten by a group gets its label re-stamped, which is the only edit ever
|
||||
// made to a raw URI.
|
||||
func rdNodeURI(n model.Node, index int) string {
|
||||
name := rdDisplayName(n, index)
|
||||
if n.RawURI != "" {
|
||||
return rdRetagURI(n.RawURI, name)
|
||||
}
|
||||
return rdDeriveURI(n, name)
|
||||
}
|
||||
|
||||
// rdRetagURI replaces the label of a raw URI when the group renamed the node.
|
||||
// When the name is unchanged the input is returned untouched, so same-format
|
||||
// rendering stays byte-identical.
|
||||
func rdRetagURI(raw, name string) string {
|
||||
if strings.HasPrefix(strings.ToLower(raw), "vmess://") {
|
||||
if out, ok := rdRetagVMess(raw, name); ok {
|
||||
return out
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(strings.ToLower(raw), "ssr://") {
|
||||
if out, ok := rdRetagSSR(raw, name); ok {
|
||||
return out
|
||||
}
|
||||
}
|
||||
if i := strings.LastIndex(raw, "#"); i >= 0 {
|
||||
if rdUnescape(raw[i+1:]) == name {
|
||||
return raw
|
||||
}
|
||||
if name == "" {
|
||||
return raw[:i]
|
||||
}
|
||||
return raw[:i] + "#" + rdEscapeFragment(name)
|
||||
}
|
||||
if name == "" {
|
||||
return raw
|
||||
}
|
||||
return raw + "#" + rdEscapeFragment(name)
|
||||
}
|
||||
|
||||
// rdRetagVMess rewrites the "ps" field inside the base64 JSON payload, which is
|
||||
// where every vmess client reads the label from — a fragment would be ignored.
|
||||
func rdRetagVMess(raw, name string) (string, bool) {
|
||||
payload := raw[len("vmess://"):]
|
||||
if i := strings.IndexByte(payload, '#'); i >= 0 {
|
||||
payload = payload[:i]
|
||||
}
|
||||
dec, enc, ok := rdDecodeB64(payload)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(dec, &m); err != nil || m == nil {
|
||||
return "", false
|
||||
}
|
||||
if rdStr(m["ps"]) == name {
|
||||
return raw, true
|
||||
}
|
||||
m["ps"] = name
|
||||
b, err := rdJSON(m)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return "vmess://" + enc.EncodeToString(b), true
|
||||
}
|
||||
|
||||
// rdRetagSSR rewrites the base64 "remarks" parameter of an SSR link.
|
||||
func rdRetagSSR(raw, name string) (string, bool) {
|
||||
payload := raw[len("ssr://"):]
|
||||
dec, enc, ok := rdDecodeB64(payload)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
body := string(dec)
|
||||
i := strings.Index(body, "/?")
|
||||
if i < 0 {
|
||||
return "", false
|
||||
}
|
||||
head, query := body[:i+2], body[i+2:]
|
||||
parts := strings.Split(query, "&")
|
||||
tag := base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString([]byte(name))
|
||||
found := false
|
||||
for j, p := range parts {
|
||||
if strings.HasPrefix(p, "remarks=") {
|
||||
if p == "remarks="+tag {
|
||||
return raw, true
|
||||
}
|
||||
parts[j] = "remarks=" + tag
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
parts = append(parts, "remarks="+tag)
|
||||
}
|
||||
return "ssr://" + enc.EncodeToString([]byte(head+strings.Join(parts, "&"))), true
|
||||
}
|
||||
|
||||
// rdDecodeB64 decodes any of the four base64 flavours subscriptions use and
|
||||
// reports which one, so a re-encode keeps the original shape.
|
||||
func rdDecodeB64(s string) ([]byte, *base64.Encoding, bool) {
|
||||
s = strings.TrimSpace(s)
|
||||
encs := []*base64.Encoding{
|
||||
base64.StdEncoding,
|
||||
base64.RawStdEncoding,
|
||||
base64.URLEncoding,
|
||||
base64.RawURLEncoding,
|
||||
}
|
||||
for _, e := range encs {
|
||||
if b, err := e.DecodeString(s); err == nil {
|
||||
return b, e, true
|
||||
}
|
||||
}
|
||||
return nil, nil, false
|
||||
}
|
||||
|
||||
func rdUnescape(s string) string {
|
||||
if out, err := url.PathUnescape(s); err == nil {
|
||||
return out
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
const rdHexDigits = "0123456789ABCDEF"
|
||||
|
||||
// rdEscapeFragment percent-encodes a node label for a URI fragment. RFC 3986
|
||||
// fragment rules: sub-delims and ":@/?" stay readable, everything else
|
||||
// (spaces, UTF-8, '#') is escaped — matching what upstream panels emit.
|
||||
func rdEscapeFragment(s string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if 'a' <= c && c <= 'z' || 'A' <= c && c <= 'Z' || '0' <= c && c <= '9' ||
|
||||
strings.IndexByte("-_.~!$&'()*+,;=:@/?", c) >= 0 {
|
||||
b.WriteByte(c)
|
||||
continue
|
||||
}
|
||||
b.WriteByte('%')
|
||||
b.WriteByte(rdHexDigits[c>>4])
|
||||
b.WriteByte(rdHexDigits[c&0x0f])
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// rdEscapeQuery encodes a query value. url.QueryEscape turns spaces into '+',
|
||||
// which several clients mis-handle inside proxy URIs, so %20 it is.
|
||||
func rdEscapeQuery(s string) string {
|
||||
return strings.ReplaceAll(url.QueryEscape(s), "+", "%20")
|
||||
}
|
||||
|
||||
// rdEscapeUserinfo encodes a credential for the userinfo part of a URI. It is
|
||||
// not query encoding: a '+' in a password is a plus, and half the clients out
|
||||
// there would read a query-encoded one back as a space.
|
||||
func rdEscapeUserinfo(s string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if 'a' <= c && c <= 'z' || 'A' <= c && c <= 'Z' || '0' <= c && c <= '9' ||
|
||||
strings.IndexByte("-_.~!$&'()*,;=", c) >= 0 {
|
||||
b.WriteByte(c)
|
||||
continue
|
||||
}
|
||||
b.WriteByte('%')
|
||||
b.WriteByte(rdHexDigits[c>>4])
|
||||
b.WriteByte(rdHexDigits[c&0x0f])
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// rdQuery builds a query string with a stable, human-readable field order.
|
||||
type rdQuery struct{ parts []string }
|
||||
|
||||
func (q *rdQuery) add(k, v string) {
|
||||
if v != "" {
|
||||
q.parts = append(q.parts, k+"="+rdEscapeQuery(v))
|
||||
}
|
||||
}
|
||||
|
||||
func (q *rdQuery) addInt(k string, v int) {
|
||||
if v != 0 {
|
||||
q.parts = append(q.parts, k+"="+strconv.Itoa(v))
|
||||
}
|
||||
}
|
||||
|
||||
func (q *rdQuery) addBool(k string, v bool) {
|
||||
if v {
|
||||
q.parts = append(q.parts, k+"=1")
|
||||
}
|
||||
}
|
||||
|
||||
func (q *rdQuery) String() string { return strings.Join(q.parts, "&") }
|
||||
|
||||
func (q *rdQuery) suffix() string {
|
||||
if len(q.parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
return "?" + q.String()
|
||||
}
|
||||
|
||||
// rdHostPort formats the authority, bracketing bare IPv6 literals.
|
||||
func rdHostPort(host string, port int) string {
|
||||
if strings.Contains(host, ":") && !strings.HasPrefix(host, "[") {
|
||||
host = "[" + host + "]"
|
||||
}
|
||||
return host + ":" + strconv.Itoa(port)
|
||||
}
|
||||
|
||||
// rdExtra pulls the first non-empty value of any alias from Node.Extra, where
|
||||
// parsers park format-specific fields that have no normalized home.
|
||||
func rdExtra(n model.Node, keys ...string) string {
|
||||
if len(n.Extra) == 0 {
|
||||
return ""
|
||||
}
|
||||
for _, k := range keys {
|
||||
v, ok := n.Extra[k]
|
||||
if !ok || v == nil {
|
||||
continue
|
||||
}
|
||||
// Parsers park list-valued leftovers here (wireguard addresses,
|
||||
// reserved bytes); URIs and Clash both want them comma-joined.
|
||||
switch list := v.(type) {
|
||||
case []string:
|
||||
if s := strings.Join(list, ","); s != "" {
|
||||
return s
|
||||
}
|
||||
continue
|
||||
case []any:
|
||||
parts := make([]string, 0, len(list))
|
||||
for _, item := range list {
|
||||
if s := rdStr(item); s != "" {
|
||||
parts = append(parts, s)
|
||||
}
|
||||
}
|
||||
if s := strings.Join(parts, ","); s != "" {
|
||||
return s
|
||||
}
|
||||
continue
|
||||
}
|
||||
if s := rdStr(v); s != "" && s != "<nil>" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// rdDeriveURI builds a proxy URI from the normalized fields. Used when converting
|
||||
// across formats (Clash/sing-box/Xray -> URI list or base64).
|
||||
func rdDeriveURI(n model.Node, name string) string {
|
||||
if n.Server == "" {
|
||||
return ""
|
||||
}
|
||||
frag := ""
|
||||
if name != "" {
|
||||
frag = "#" + rdEscapeFragment(name)
|
||||
}
|
||||
auth := rdHostPort(n.Server, n.Port)
|
||||
|
||||
switch rdProto(n.Protocol) {
|
||||
case model.ProtoVLESS:
|
||||
q := &rdQuery{}
|
||||
q.add("encryption", rdFirst(rdExtra(n, "encryption"), "none"))
|
||||
q.add("flow", n.Flow)
|
||||
q.add("type", rdFirst(n.Network, "tcp"))
|
||||
q.add("security", rdFirst(n.Security, "none"))
|
||||
rdAddTLSQuery(q, n)
|
||||
rdAddTransportQuery(q, n)
|
||||
return "vless://" + n.UUID + "@" + auth + q.suffix() + frag
|
||||
|
||||
case model.ProtoVMess:
|
||||
return rdDeriveVMess(n, name)
|
||||
|
||||
case model.ProtoTrojan:
|
||||
q := &rdQuery{}
|
||||
q.add("type", rdFirst(n.Network, "tcp"))
|
||||
q.add("security", rdFirst(n.Security, "tls"))
|
||||
rdAddTLSQuery(q, n)
|
||||
rdAddTransportQuery(q, n)
|
||||
return "trojan://" + rdEscapeUserinfo(n.Password) + "@" + auth + q.suffix() + frag
|
||||
|
||||
case model.ProtoShadowsock:
|
||||
userinfo := base64.StdEncoding.EncodeToString([]byte(n.Method + ":" + n.Password))
|
||||
q := &rdQuery{}
|
||||
q.add("plugin", rdExtra(n, "plugin"))
|
||||
return "ss://" + userinfo + "@" + auth + q.suffix() + frag
|
||||
|
||||
case model.ProtoSSR:
|
||||
return rdDeriveSSR(n, name)
|
||||
|
||||
case model.ProtoHysteria2:
|
||||
q := &rdQuery{}
|
||||
q.add("sni", rdFirst(n.SNI, n.Host))
|
||||
q.addBool("insecure", n.AllowInsecure)
|
||||
q.add("obfs", n.Obfs)
|
||||
q.add("obfs-password", n.ObfsPassword)
|
||||
q.add("alpn", strings.Join(n.ALPN, ","))
|
||||
q.addInt("up", n.UpMbps)
|
||||
q.addInt("down", n.DownMbps)
|
||||
return "hysteria2://" + rdEscapeUserinfo(rdFirst(n.Password, n.UUID)) + "@" + auth + q.suffix() + frag
|
||||
|
||||
case model.ProtoHysteria:
|
||||
q := &rdQuery{}
|
||||
q.add("protocol", rdFirst(rdExtra(n, "protocol"), "udp"))
|
||||
q.add("auth", rdFirst(n.Password, n.UUID))
|
||||
q.add("peer", rdFirst(n.SNI, n.Host))
|
||||
q.addBool("insecure", n.AllowInsecure)
|
||||
q.addInt("upmbps", n.UpMbps)
|
||||
q.addInt("downmbps", n.DownMbps)
|
||||
q.add("obfs", n.Obfs)
|
||||
q.add("alpn", strings.Join(n.ALPN, ","))
|
||||
return "hysteria://" + auth + q.suffix() + frag
|
||||
|
||||
case model.ProtoTUIC:
|
||||
q := &rdQuery{}
|
||||
q.add("sni", rdFirst(n.SNI, n.Host))
|
||||
q.add("congestion_control", n.Congestion)
|
||||
q.add("alpn", strings.Join(n.ALPN, ","))
|
||||
q.add("udp_relay_mode", rdExtra(n, "udp_relay_mode", "udp-relay-mode"))
|
||||
q.addBool("allow_insecure", n.AllowInsecure)
|
||||
cred := rdEscapeUserinfo(n.UUID)
|
||||
if n.Password != "" {
|
||||
cred += ":" + rdEscapeUserinfo(n.Password)
|
||||
}
|
||||
return "tuic://" + cred + "@" + auth + q.suffix() + frag
|
||||
|
||||
case model.ProtoAnyTLS:
|
||||
q := &rdQuery{}
|
||||
q.add("sni", rdFirst(n.SNI, n.Host))
|
||||
q.addBool("insecure", n.AllowInsecure)
|
||||
q.add("fp", n.Fingerprint)
|
||||
q.add("alpn", strings.Join(n.ALPN, ","))
|
||||
return "anytls://" + rdEscapeUserinfo(n.Password) + "@" + auth + q.suffix() + frag
|
||||
|
||||
case model.ProtoSOCKS:
|
||||
user := rdExtra(n, "username", "user")
|
||||
if user == "" && n.Password == "" {
|
||||
return "socks://" + auth + frag
|
||||
}
|
||||
// v2rayN encodes socks credentials as base64(user:pass).
|
||||
userinfo := base64.StdEncoding.EncodeToString([]byte(user + ":" + n.Password))
|
||||
return "socks://" + userinfo + "@" + auth + frag
|
||||
|
||||
case model.ProtoHTTP:
|
||||
scheme := "http"
|
||||
if n.Security == "tls" || rdExtraBool(n, "tls") {
|
||||
scheme = "https"
|
||||
}
|
||||
user := rdExtra(n, "username", "user")
|
||||
if user == "" && n.Password == "" {
|
||||
return scheme + "://" + auth + frag
|
||||
}
|
||||
return scheme + "://" + rdEscapeUserinfo(user) + ":" + rdEscapeUserinfo(n.Password) + "@" + auth + frag
|
||||
|
||||
case model.ProtoSSH:
|
||||
user := rdExtra(n, "username", "user")
|
||||
if user == "" {
|
||||
return "ssh://" + auth + frag
|
||||
}
|
||||
return "ssh://" + rdEscapeUserinfo(user) + ":" + rdEscapeUserinfo(n.Password) + "@" + auth + frag
|
||||
|
||||
case model.ProtoWireGuard:
|
||||
q := &rdQuery{}
|
||||
q.add("publickey", rdFirst(rdExtra(n, "public_key", "publicKey", "public-key"), n.PublicKey))
|
||||
q.add("presharedkey", rdExtra(n, "pre_shared_key", "preSharedKey", "pre-shared-key"))
|
||||
q.add("address", rdExtra(n, "address", "local_address", "ip"))
|
||||
q.add("reserved", rdExtra(n, "reserved"))
|
||||
q.add("mtu", rdExtra(n, "mtu"))
|
||||
priv := rdFirst(rdExtra(n, "private_key", "privateKey", "private-key", "secretKey"), n.Password, n.UUID)
|
||||
return "wireguard://" + rdEscapeUserinfo(priv) + "@" + auth + q.suffix() + frag
|
||||
|
||||
default:
|
||||
if n.Protocol == "" {
|
||||
return ""
|
||||
}
|
||||
// Unknown scheme: keep the node alive rather than dropping it.
|
||||
q := &rdQuery{}
|
||||
q.add("password", n.Password)
|
||||
q.add("uuid", n.UUID)
|
||||
return strings.ToLower(n.Protocol) + "://" + auth + q.suffix() + frag
|
||||
}
|
||||
}
|
||||
|
||||
// rdAddTLSQuery appends the security parameters shared by vless/trojan URIs.
|
||||
func rdAddTLSQuery(q *rdQuery, n model.Node) {
|
||||
q.add("sni", rdFirst(n.SNI, n.Host))
|
||||
q.add("fp", n.Fingerprint)
|
||||
q.add("pbk", n.PublicKey)
|
||||
q.add("sid", n.ShortID)
|
||||
q.add("spx", n.SpiderX)
|
||||
q.add("alpn", strings.Join(n.ALPN, ","))
|
||||
q.addBool("allowInsecure", n.AllowInsecure)
|
||||
}
|
||||
|
||||
// rdAddTransportQuery appends the network-specific parameters.
|
||||
func rdAddTransportQuery(q *rdQuery, n model.Node) {
|
||||
switch n.Network {
|
||||
case "grpc":
|
||||
q.add("serviceName", rdFirst(n.ServiceName, n.Path))
|
||||
q.add("mode", rdExtra(n, "mode"))
|
||||
case "kcp", "quic":
|
||||
q.add("headerType", n.HeaderType)
|
||||
q.add("seed", n.Seed)
|
||||
q.add("path", n.Path)
|
||||
q.add("host", n.Host)
|
||||
default:
|
||||
q.add("path", n.Path)
|
||||
q.add("host", n.Host)
|
||||
if n.HeaderType != "" && n.HeaderType != "none" {
|
||||
q.add("headerType", n.HeaderType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// rdDeriveVMess writes the v2rayN base64-JSON form, the only one every vmess
|
||||
// client reads.
|
||||
func rdDeriveVMess(n model.Node, name string) string {
|
||||
tls := ""
|
||||
if n.Security != "" && n.Security != "none" {
|
||||
tls = n.Security
|
||||
}
|
||||
m := map[string]any{
|
||||
"v": "2",
|
||||
"ps": name,
|
||||
"add": n.Server,
|
||||
"port": strconv.Itoa(n.Port),
|
||||
"id": n.UUID,
|
||||
"aid": strconv.Itoa(n.AlterID),
|
||||
"scy": rdFirst(n.Method, "auto"),
|
||||
"net": rdFirst(n.Network, "tcp"),
|
||||
"type": rdFirst(n.HeaderType, "none"),
|
||||
"host": n.Host,
|
||||
"path": rdPathOrService(n),
|
||||
"tls": tls,
|
||||
}
|
||||
rdSet(m, "sni", n.SNI)
|
||||
rdSet(m, "fp", n.Fingerprint)
|
||||
rdSet(m, "alpn", strings.Join(n.ALPN, ","))
|
||||
b, err := rdJSON(m)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return "vmess://" + base64.StdEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
func rdPathOrService(n model.Node) string {
|
||||
if n.Network == "grpc" && n.ServiceName != "" {
|
||||
return n.ServiceName
|
||||
}
|
||||
return n.Path
|
||||
}
|
||||
|
||||
// rdDeriveSSR builds the fully nested legacy SSR link.
|
||||
func rdDeriveSSR(n model.Node, name string) string {
|
||||
b64 := base64.URLEncoding.WithPadding(base64.NoPadding)
|
||||
proto := rdFirst(rdExtra(n, "protocol"), "origin")
|
||||
obfs := rdFirst(n.Obfs, "plain")
|
||||
body := strings.Join([]string{
|
||||
n.Server,
|
||||
strconv.Itoa(n.Port),
|
||||
proto,
|
||||
rdFirst(n.Method, "none"),
|
||||
obfs,
|
||||
b64.EncodeToString([]byte(n.Password)),
|
||||
}, ":")
|
||||
q := &rdQuery{}
|
||||
if v := rdExtra(n, "obfs_param", "obfs-param", "obfsparam"); v != "" {
|
||||
q.parts = append(q.parts, "obfsparam="+b64.EncodeToString([]byte(v)))
|
||||
}
|
||||
if v := rdExtra(n, "protocol_param", "protocol-param", "protoparam"); v != "" {
|
||||
q.parts = append(q.parts, "protoparam="+b64.EncodeToString([]byte(v)))
|
||||
}
|
||||
q.parts = append(q.parts, "remarks="+b64.EncodeToString([]byte(name)))
|
||||
return "ssr://" + b64.EncodeToString([]byte(body+"/?"+q.String()))
|
||||
}
|
||||
|
||||
func rdFirst(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func rdExtraBool(n model.Node, keys ...string) bool {
|
||||
if len(n.Extra) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, k := range keys {
|
||||
switch v := n.Extra[k].(type) {
|
||||
case bool:
|
||||
if v {
|
||||
return true
|
||||
}
|
||||
case string:
|
||||
if v == "true" || v == "1" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// rdProto folds protocol spellings onto the model.Proto* vocabulary so a
|
||||
// renderer never has to test three aliases.
|
||||
func rdProto(p string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(p)) {
|
||||
case "ss", "shadowsocks":
|
||||
return model.ProtoShadowsock
|
||||
case "ssr", "shadowsocksr":
|
||||
return model.ProtoSSR
|
||||
case "hy", "hysteria", "hysteria1":
|
||||
return model.ProtoHysteria
|
||||
case "hy2", "hysteria2":
|
||||
return model.ProtoHysteria2
|
||||
case "socks", "socks5", "socks4", "socks4a", "socks5h":
|
||||
return model.ProtoSOCKS
|
||||
case "http", "https", "http-proxy":
|
||||
return model.ProtoHTTP
|
||||
case "wg", "wireguard":
|
||||
return model.ProtoWireGuard
|
||||
default:
|
||||
return strings.ToLower(strings.TrimSpace(p))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
package subfmt
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/omar/prizma/internal/model"
|
||||
)
|
||||
|
||||
// renderXray writes the Happ-style JSON ARRAY of whole Xray configs — one
|
||||
// element per node, labelled by "remarks".
|
||||
//
|
||||
// The array form is what Happ and the Remnawave-flavoured panels ship, and it
|
||||
// is the only Xray shape that survives a round trip unambiguously: one config
|
||||
// is one node, so per-node routing, dns and fragment blocks travel with the
|
||||
// server they belong to. A node parsed from Xray keeps its whole config
|
||||
// verbatim; a node converted from another format is dropped into the skeleton
|
||||
// (the caller's template, else the first Xray node's config, else a built-in).
|
||||
func renderXray(nodes []model.Node, opts RenderOpts) ([]byte, error) {
|
||||
skel := rdPickTemplate(opts, nodes, rdDocIsXray)
|
||||
|
||||
configs := make([]any, 0, len(nodes))
|
||||
seen := make(map[string]int, len(nodes))
|
||||
for i, n := range nodes {
|
||||
var cfg map[string]any
|
||||
if rdDocIsXray(n.RawDoc) {
|
||||
cfg = rdCopyMap(n.RawDoc)
|
||||
} else {
|
||||
cfg = rdXrayConfig(n, skel)
|
||||
}
|
||||
if cfg == nil {
|
||||
continue
|
||||
}
|
||||
cfg["remarks"] = rdUniqueName(rdDisplayName(n, i), seen)
|
||||
configs = append(configs, cfg)
|
||||
}
|
||||
return rdJSON(configs)
|
||||
}
|
||||
|
||||
// rdXrayConfig wraps a single converted node in a complete Xray config.
|
||||
func rdXrayConfig(n model.Node, skel map[string]any) map[string]any {
|
||||
ob := rdXrayOutbound(n)
|
||||
if ob == nil {
|
||||
return nil
|
||||
}
|
||||
ob["tag"] = "proxy"
|
||||
|
||||
var cfg map[string]any
|
||||
if skel != nil {
|
||||
cfg = rdCopyMap(skel)
|
||||
// The skeleton belongs to a different server: its description would lie.
|
||||
delete(cfg, "meta")
|
||||
} else {
|
||||
cfg = rdDefaultXrayDoc()
|
||||
}
|
||||
// The tags below are the ones every Xray routing block in the wild refers
|
||||
// to, so a reused skeleton's rules keep resolving.
|
||||
cfg["outbounds"] = []any{
|
||||
ob,
|
||||
map[string]any{"tag": "direct", "protocol": "freedom"},
|
||||
map[string]any{
|
||||
"tag": "block",
|
||||
"protocol": "blackhole",
|
||||
"settings": map[string]any{"response": map[string]any{"type": "http"}},
|
||||
},
|
||||
map[string]any{"tag": "dns-out", "protocol": "dns"},
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func rdDefaultXrayDoc() map[string]any {
|
||||
return map[string]any{
|
||||
"log": map[string]any{"loglevel": "warning"},
|
||||
"dns": map[string]any{
|
||||
"servers": []any{"https://1.1.1.1/dns-query", "https://8.8.8.8/dns-query"},
|
||||
"queryStrategy": "UseIP",
|
||||
},
|
||||
"inbounds": []any{
|
||||
map[string]any{
|
||||
"tag": "socks", "port": 10808, "listen": "127.0.0.1", "protocol": "socks",
|
||||
"settings": map[string]any{"udp": true, "auth": "noauth"},
|
||||
"sniffing": map[string]any{"enabled": true, "destOverride": []any{"http", "tls", "quic"}},
|
||||
},
|
||||
map[string]any{
|
||||
"tag": "http", "port": 10809, "listen": "127.0.0.1", "protocol": "http",
|
||||
"settings": map[string]any{"allowTransparent": false},
|
||||
},
|
||||
},
|
||||
"routing": map[string]any{
|
||||
"domainMatcher": "hybrid",
|
||||
"domainStrategy": "IPIfNonMatch",
|
||||
"rules": []any{
|
||||
map[string]any{"type": "field", "ip": []any{"geoip:private"}, "outboundTag": "direct"},
|
||||
map[string]any{"type": "field", "protocol": []any{"bittorrent"}, "outboundTag": "direct"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// rdXrayOutbound returns the node's own Xray outbound when it has one, and
|
||||
// derives one otherwise.
|
||||
func rdXrayOutbound(n model.Node) map[string]any {
|
||||
if len(n.RawXray) > 0 {
|
||||
return rdCopyMap(n.RawXray)
|
||||
}
|
||||
return rdDeriveXrayOutbound(n)
|
||||
}
|
||||
|
||||
func rdDeriveXrayOutbound(n model.Node) map[string]any {
|
||||
if n.Server == "" || n.Port == 0 {
|
||||
return nil
|
||||
}
|
||||
proto := rdProto(n.Protocol)
|
||||
o := map[string]any{"protocol": proto}
|
||||
|
||||
switch proto {
|
||||
case model.ProtoVLESS:
|
||||
user := map[string]any{"id": n.UUID, "encryption": rdFirst(rdExtra(n, "encryption"), "none")}
|
||||
rdSet(user, "flow", n.Flow)
|
||||
o["settings"] = map[string]any{"vnext": []any{map[string]any{
|
||||
"address": n.Server, "port": n.Port, "users": []any{user},
|
||||
}}}
|
||||
o["streamSettings"] = rdXrayStream(n)
|
||||
|
||||
case model.ProtoVMess:
|
||||
user := map[string]any{"id": n.UUID, "alterId": n.AlterID, "security": rdFirst(n.Method, "auto")}
|
||||
o["settings"] = map[string]any{"vnext": []any{map[string]any{
|
||||
"address": n.Server, "port": n.Port, "users": []any{user},
|
||||
}}}
|
||||
o["streamSettings"] = rdXrayStream(n)
|
||||
|
||||
case model.ProtoTrojan:
|
||||
srv := map[string]any{"address": n.Server, "port": n.Port, "password": n.Password}
|
||||
rdSet(srv, "flow", n.Flow)
|
||||
o["settings"] = map[string]any{"servers": []any{srv}}
|
||||
o["streamSettings"] = rdXrayStream(n)
|
||||
|
||||
case model.ProtoShadowsock:
|
||||
o["protocol"] = "shadowsocks"
|
||||
o["settings"] = map[string]any{"servers": []any{map[string]any{
|
||||
"address": n.Server, "port": n.Port,
|
||||
"method": rdFirst(n.Method, "aes-128-gcm"), "password": n.Password,
|
||||
"uot": true,
|
||||
}}}
|
||||
|
||||
case model.ProtoSOCKS, model.ProtoHTTP:
|
||||
srv := map[string]any{"address": n.Server, "port": n.Port}
|
||||
if u := rdExtra(n, "username", "user"); u != "" || n.Password != "" {
|
||||
srv["users"] = []any{map[string]any{"user": u, "pass": n.Password}}
|
||||
}
|
||||
o["settings"] = map[string]any{"servers": []any{srv}}
|
||||
if n.Security == "tls" || rdExtraBool(n, "tls") {
|
||||
o["streamSettings"] = rdXrayStream(n)
|
||||
}
|
||||
|
||||
case model.ProtoHysteria, model.ProtoHysteria2:
|
||||
// The shape Happ uses for both hysteria generations.
|
||||
version := 2
|
||||
if proto == model.ProtoHysteria {
|
||||
version = 1
|
||||
}
|
||||
o["protocol"] = "hysteria"
|
||||
o["settings"] = map[string]any{"address": n.Server, "port": n.Port, "version": version}
|
||||
hy := map[string]any{"version": version, "auth": rdFirst(n.Password, n.UUID)}
|
||||
rdSet(hy, "obfs", n.Obfs)
|
||||
rdSet(hy, "obfsPassword", n.ObfsPassword)
|
||||
st := map[string]any{
|
||||
"network": "hysteria",
|
||||
"hysteriaSettings": hy,
|
||||
"security": "tls",
|
||||
"tlsSettings": rdXrayTLSSettings(n, []string{"h3"}),
|
||||
}
|
||||
o["streamSettings"] = st
|
||||
|
||||
case model.ProtoWireGuard:
|
||||
peer := map[string]any{
|
||||
"endpoint": rdHostPort(n.Server, n.Port),
|
||||
}
|
||||
rdSet(peer, "publicKey", rdFirst(rdExtra(n, "public_key", "publicKey", "public-key"), n.PublicKey))
|
||||
rdSet(peer, "preSharedKey", rdExtra(n, "pre_shared_key", "preSharedKey", "pre-shared-key"))
|
||||
settings := map[string]any{"peers": []any{peer}}
|
||||
rdSet(settings, "secretKey", rdFirst(rdExtra(n, "private_key", "privateKey", "private-key", "secretKey"), n.Password))
|
||||
if addr := rdExtra(n, "address", "local_address", "ip"); addr != "" {
|
||||
list := []any{}
|
||||
for _, p := range strings.Split(addr, ",") {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
list = append(list, p)
|
||||
}
|
||||
}
|
||||
settings["address"] = list
|
||||
}
|
||||
o["settings"] = settings
|
||||
|
||||
case model.ProtoTUIC:
|
||||
// The forks that speak TUIC put the endpoint straight on settings.
|
||||
s := map[string]any{"address": n.Server, "port": n.Port}
|
||||
rdSet(s, "uuid", n.UUID)
|
||||
rdSet(s, "password", n.Password)
|
||||
rdSet(s, "congestion", n.Congestion)
|
||||
o["settings"] = s
|
||||
o["streamSettings"] = map[string]any{
|
||||
"network": "tcp",
|
||||
"security": "tls",
|
||||
"tlsSettings": rdXrayTLSSettings(n, nil),
|
||||
}
|
||||
|
||||
case model.ProtoAnyTLS:
|
||||
srv := map[string]any{"address": n.Server, "port": n.Port, "password": n.Password}
|
||||
o["settings"] = map[string]any{"servers": []any{srv}}
|
||||
o["streamSettings"] = rdXrayStream(n)
|
||||
|
||||
default:
|
||||
// SSR and friends have no upstream Xray outbound. Writing the endpoint
|
||||
// in both conventional shapes keeps the node visible to the clients
|
||||
// that do implement it instead of silently losing it.
|
||||
if proto == "" {
|
||||
return nil
|
||||
}
|
||||
srv := map[string]any{"address": n.Server, "port": n.Port}
|
||||
rdSet(srv, "uuid", n.UUID)
|
||||
rdSet(srv, "password", n.Password)
|
||||
rdSet(srv, "method", n.Method)
|
||||
o["settings"] = map[string]any{
|
||||
"address": n.Server,
|
||||
"port": n.Port,
|
||||
"servers": []any{srv},
|
||||
}
|
||||
o["streamSettings"] = rdXrayStream(n)
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// rdXrayTLSSettings builds tlsSettings, defaulting ALPN when the caller knows
|
||||
// the protocol requires one (hysteria needs h3).
|
||||
func rdXrayTLSSettings(n model.Node, defaultALPN []string) map[string]any {
|
||||
t := map[string]any{}
|
||||
rdSet(t, "serverName", rdFirst(n.SNI, n.Host, n.Server))
|
||||
rdSet(t, "fingerprint", n.Fingerprint)
|
||||
if n.AllowInsecure {
|
||||
t["allowInsecure"] = true
|
||||
}
|
||||
alpn := n.ALPN
|
||||
if len(alpn) == 0 {
|
||||
alpn = defaultALPN
|
||||
}
|
||||
if len(alpn) > 0 {
|
||||
list := make([]any, len(alpn))
|
||||
for i, a := range alpn {
|
||||
list[i] = a
|
||||
}
|
||||
t["alpn"] = list
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// rdXrayStream builds streamSettings from the normalized transport fields.
|
||||
func rdXrayStream(n model.Node) map[string]any {
|
||||
network := rdFirst(n.Network, "tcp")
|
||||
st := map[string]any{"network": network}
|
||||
|
||||
switch n.Security {
|
||||
case "reality":
|
||||
st["security"] = "reality"
|
||||
r := map[string]any{}
|
||||
rdSet(r, "serverName", rdFirst(n.SNI, n.Host, n.Server))
|
||||
rdSet(r, "publicKey", n.PublicKey)
|
||||
rdSet(r, "shortId", n.ShortID)
|
||||
rdSet(r, "fingerprint", n.Fingerprint)
|
||||
rdSet(r, "spiderX", n.SpiderX)
|
||||
st["realitySettings"] = r
|
||||
case "tls", "xtls":
|
||||
st["security"] = "tls"
|
||||
st["tlsSettings"] = rdXrayTLSSettings(n, nil)
|
||||
default:
|
||||
st["security"] = "none"
|
||||
}
|
||||
|
||||
switch network {
|
||||
case "ws":
|
||||
ws := map[string]any{"path": rdFirst(n.Path, "/")}
|
||||
if n.Host != "" {
|
||||
ws["headers"] = map[string]any{"Host": n.Host}
|
||||
}
|
||||
st["wsSettings"] = ws
|
||||
case "grpc":
|
||||
g := map[string]any{"serviceName": rdFirst(n.ServiceName, strings.TrimPrefix(n.Path, "/"))}
|
||||
rdSet(g, "multiMode", rdExtra(n, "mode"))
|
||||
st["grpcSettings"] = g
|
||||
case "http", "h2":
|
||||
h := map[string]any{}
|
||||
rdSet(h, "path", n.Path)
|
||||
if n.Host != "" {
|
||||
h["host"] = []any{n.Host}
|
||||
}
|
||||
st["httpSettings"] = h
|
||||
case "httpupgrade":
|
||||
h := map[string]any{}
|
||||
rdSet(h, "path", n.Path)
|
||||
rdSet(h, "host", n.Host)
|
||||
st["httpupgradeSettings"] = h
|
||||
case "xhttp":
|
||||
x := map[string]any{}
|
||||
rdSet(x, "path", n.Path)
|
||||
rdSet(x, "host", n.Host)
|
||||
rdSet(x, "mode", rdExtra(n, "mode"))
|
||||
st["xhttpSettings"] = x
|
||||
case "kcp":
|
||||
k := map[string]any{"header": map[string]any{"type": rdFirst(n.HeaderType, "none")}}
|
||||
rdSet(k, "seed", n.Seed)
|
||||
st["kcpSettings"] = k
|
||||
case "quic":
|
||||
q := map[string]any{"header": map[string]any{"type": rdFirst(n.HeaderType, "none")}}
|
||||
rdSet(q, "security", n.Obfs)
|
||||
rdSet(q, "key", n.Password)
|
||||
st["quicSettings"] = q
|
||||
default: // tcp
|
||||
tcp := map[string]any{}
|
||||
if n.HeaderType == "http" {
|
||||
req := map[string]any{}
|
||||
if n.Path != "" {
|
||||
req["path"] = []any{n.Path}
|
||||
}
|
||||
if n.Host != "" {
|
||||
req["headers"] = map[string]any{"Host": []any{n.Host}}
|
||||
}
|
||||
tcp["header"] = map[string]any{"type": "http", "request": req}
|
||||
}
|
||||
st["tcpSettings"] = tcp
|
||||
}
|
||||
return st
|
||||
}
|
||||
@@ -0,0 +1,930 @@
|
||||
package subfmt
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/omar/prizma/internal/model"
|
||||
)
|
||||
|
||||
// The four captures are real bodies from a live Remnawave-style panel: the same
|
||||
// 34-server subscription as every client sees it. They are the quality gate for
|
||||
// the whole package — a change that loses a node, a name or a vendor field in
|
||||
// any direction fails here.
|
||||
var tstCaptures = []struct {
|
||||
file string
|
||||
want Format
|
||||
}{
|
||||
{"live_b64.txt", FormatBase64},
|
||||
{"live_clash.yaml", FormatClash},
|
||||
{"live_happ.json", FormatXray},
|
||||
{"live_v2rayn.txt", FormatXray},
|
||||
}
|
||||
|
||||
const tstNodeCount = 34
|
||||
|
||||
var tstFormats = []Format{FormatURIList, FormatBase64, FormatClash, FormatSingBox, FormatXray}
|
||||
|
||||
func tstLoad(t *testing.T, name string) []byte {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(filepath.Join("testdata", name))
|
||||
if err != nil {
|
||||
t.Fatalf("read capture %s: %v", name, err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func tstNames(nodes []model.Node) []string {
|
||||
out := make([]string, len(nodes))
|
||||
for i, n := range nodes {
|
||||
out[i] = n.Name
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func tstParse(t *testing.T, file string) []model.Node {
|
||||
t.Helper()
|
||||
nodes, _, err := Parse(tstLoad(t, file), "")
|
||||
if err != nil {
|
||||
t.Fatalf("parse %s: %v", file, err)
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parsing the captures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestParseCaptures(t *testing.T) {
|
||||
for _, tc := range tstCaptures {
|
||||
t.Run(tc.file, func(t *testing.T) {
|
||||
body := tstLoad(t, tc.file)
|
||||
|
||||
if got := Detect(body, ""); got != tc.want {
|
||||
t.Errorf("Detect = %q, want %q", got, tc.want)
|
||||
}
|
||||
nodes, format, err := Parse(body, "")
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
if format != tc.want {
|
||||
t.Errorf("Parse format = %q, want %q", format, tc.want)
|
||||
}
|
||||
if len(nodes) != tstNodeCount {
|
||||
t.Fatalf("Parse gave %d nodes, want %d", len(nodes), tstNodeCount)
|
||||
}
|
||||
for i, n := range nodes {
|
||||
if n.Name == "" {
|
||||
t.Errorf("node %d has no name", i)
|
||||
}
|
||||
if n.Server == "" || n.Port == 0 {
|
||||
t.Errorf("node %d (%s) has no endpoint: %q:%d", i, n.Name, n.Server, n.Port)
|
||||
}
|
||||
if n.Protocol == "" {
|
||||
t.Errorf("node %d (%s) has no protocol", i, n.Name)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCapturesAgreeOnNames pins the cross-format identity the whole product
|
||||
// depends on: the four bodies are the same subscription, so a group built from
|
||||
// any of them must show the user the same list in the same order.
|
||||
func TestCapturesAgreeOnNames(t *testing.T) {
|
||||
var want []string
|
||||
for _, tc := range tstCaptures {
|
||||
got := tstNames(tstParse(t, tc.file))
|
||||
if want == nil {
|
||||
want = got
|
||||
continue
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("%s names differ from %s:\n got %q\nwant %q",
|
||||
tc.file, tstCaptures[0].file, got, want)
|
||||
}
|
||||
}
|
||||
if len(want) != tstNodeCount {
|
||||
t.Fatalf("reference name list has %d entries, want %d", len(want), tstNodeCount)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// render -> re-parse round trips
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestRenderRoundTripEveryFormat(t *testing.T) {
|
||||
for _, tc := range tstCaptures {
|
||||
src := tstParse(t, tc.file)
|
||||
wantNames := tstNames(src)
|
||||
|
||||
for _, f := range tstFormats {
|
||||
t.Run(tc.file+"->"+string(f), func(t *testing.T) {
|
||||
body, ct, err := Render(src, f, RenderOpts{ProfileTitle: "Prizma"})
|
||||
if err != nil {
|
||||
t.Fatalf("Render: %v", err)
|
||||
}
|
||||
if ct != ContentTypeFor(f) {
|
||||
t.Errorf("content type = %q, want %q", ct, ContentTypeFor(f))
|
||||
}
|
||||
if len(body) == 0 {
|
||||
t.Fatal("rendered an empty body")
|
||||
}
|
||||
if got := Detect(body, ct); got != f && !(f == FormatURIList && got == FormatBase64) {
|
||||
t.Errorf("rendered body detects as %q, want %q", got, f)
|
||||
}
|
||||
|
||||
back, _, err := Parse(body, ct)
|
||||
if err != nil {
|
||||
t.Fatalf("re-parse: %v", err)
|
||||
}
|
||||
if len(back) != len(src) {
|
||||
t.Fatalf("round trip gave %d nodes, want %d", len(back), len(src))
|
||||
}
|
||||
if got := tstNames(back); !reflect.DeepEqual(got, wantNames) {
|
||||
t.Errorf("names lost in round trip:\n got %q\nwant %q", got, wantNames)
|
||||
}
|
||||
for i, n := range back {
|
||||
if n.Server != src[i].Server {
|
||||
t.Errorf("node %d (%s): server %q, want %q", i, n.Name, n.Server, src[i].Server)
|
||||
}
|
||||
if n.Port != src[i].Port {
|
||||
t.Errorf("node %d (%s): port %d, want %d", i, n.Name, n.Port, src[i].Port)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// same-format fidelity
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestRenderURIListIsByteIdentical is the strictest form of the fidelity rule:
|
||||
// a URI subscription rendered back as a URI subscription must be the upstream's
|
||||
// own bytes, because RawURI is emitted verbatim.
|
||||
func TestRenderURIListIsByteIdentical(t *testing.T) {
|
||||
raw := strings.TrimSpace(string(tstLoad(t, "live_b64.txt")))
|
||||
decoded, err := base64.StdEncoding.DecodeString(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("decode capture: %v", err)
|
||||
}
|
||||
nodes := tstParse(t, "live_b64.txt")
|
||||
|
||||
uri, _, err := Render(nodes, FormatURIList, RenderOpts{})
|
||||
if err != nil {
|
||||
t.Fatalf("render uri: %v", err)
|
||||
}
|
||||
if string(uri) != string(decoded) {
|
||||
t.Errorf("URI list is not byte-identical to the upstream body")
|
||||
tstDiffLines(t, strings.Split(string(decoded), "\n"), strings.Split(string(uri), "\n"))
|
||||
}
|
||||
|
||||
b64, _, err := Render(nodes, FormatBase64, RenderOpts{})
|
||||
if err != nil {
|
||||
t.Fatalf("render base64: %v", err)
|
||||
}
|
||||
if string(b64) != raw {
|
||||
t.Errorf("base64 blob is not byte-identical to the upstream body")
|
||||
}
|
||||
}
|
||||
|
||||
func tstDiffLines(t *testing.T, want, got []string) {
|
||||
t.Helper()
|
||||
for i := range want {
|
||||
if i >= len(got) {
|
||||
t.Logf("line %d missing: %s", i, want[i])
|
||||
continue
|
||||
}
|
||||
if want[i] != got[i] {
|
||||
t.Logf("line %d:\n want %s\n got %s", i, want[i], got[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderClashKeepsRawProxies checks that Clash -> Clash keeps every
|
||||
// vendor-specific key of every proxy (udp-over-tcp-version, client-fingerprint,
|
||||
// reality-opts…) and the document blocks Prizma does not understand.
|
||||
func TestRenderClashKeepsRawProxies(t *testing.T) {
|
||||
src := tstParse(t, "live_clash.yaml")
|
||||
body, _, err := Render(src, FormatClash, RenderOpts{})
|
||||
if err != nil {
|
||||
t.Fatalf("render clash: %v", err)
|
||||
}
|
||||
|
||||
var doc map[string]any
|
||||
if err := yaml.Unmarshal(body, &doc); err != nil {
|
||||
t.Fatalf("rendered clash is not valid YAML: %v", err)
|
||||
}
|
||||
for _, key := range []string{"dns", "rules", "proxies", "proxy-groups", "mixed-port"} {
|
||||
if _, ok := doc[key]; !ok {
|
||||
t.Errorf("document block %q was dropped", key)
|
||||
}
|
||||
}
|
||||
|
||||
proxies, _ := doc["proxies"].([]any)
|
||||
if len(proxies) != len(src) {
|
||||
t.Fatalf("rendered %d proxies, want %d", len(proxies), len(src))
|
||||
}
|
||||
for i, p := range proxies {
|
||||
got, _ := p.(map[string]any)
|
||||
want := src[i].RawClash
|
||||
if want == nil {
|
||||
t.Fatalf("node %d lost its RawClash payload", i)
|
||||
}
|
||||
if !reflect.DeepEqual(got, map[string]any(want)) {
|
||||
t.Errorf("proxy %d (%s) was not emitted verbatim:\n got %#v\nwant %#v",
|
||||
i, src[i].Name, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Every proxy-group entry must name something that exists.
|
||||
tstAssertClashRefs(t, doc)
|
||||
}
|
||||
|
||||
func tstAssertClashRefs(t *testing.T, doc map[string]any) {
|
||||
t.Helper()
|
||||
known := map[string]bool{"DIRECT": true, "REJECT": true, "REJECT-DROP": true, "PASS": true, "GLOBAL": true, "COMPATIBLE": true}
|
||||
for _, p := range rdSlice(doc["proxies"]) {
|
||||
known[rdStr(rdMap(p)["name"])] = true
|
||||
}
|
||||
groups := rdSlice(doc["proxy-groups"])
|
||||
for _, g := range groups {
|
||||
known[rdStr(rdMap(g)["name"])] = true
|
||||
}
|
||||
for _, g := range groups {
|
||||
gm := rdMap(g)
|
||||
list := rdSlice(gm["proxies"])
|
||||
if len(list) == 0 {
|
||||
t.Errorf("proxy-group %q ended up empty", rdStr(gm["name"]))
|
||||
}
|
||||
for _, ref := range list {
|
||||
if !known[rdStr(ref)] {
|
||||
t.Errorf("proxy-group %q references unknown proxy %q", rdStr(gm["name"]), rdStr(ref))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderXrayKeepsWholeConfigs checks the Happ array: Xray -> Xray must
|
||||
// reproduce each node's entire config, dns/routing/burstObservatory included.
|
||||
func TestRenderXrayKeepsWholeConfigs(t *testing.T) {
|
||||
original := tstLoad(t, "live_happ.json")
|
||||
src := tstParse(t, "live_happ.json")
|
||||
|
||||
body, _, err := Render(src, FormatXray, RenderOpts{})
|
||||
if err != nil {
|
||||
t.Fatalf("render xray: %v", err)
|
||||
}
|
||||
|
||||
var want, got []map[string]any
|
||||
if err := json.Unmarshal(original, &want); err != nil {
|
||||
t.Fatalf("capture is not a JSON array: %v", err)
|
||||
}
|
||||
if err := json.Unmarshal(body, &got); err != nil {
|
||||
t.Fatalf("rendered xray is not a JSON array: %v", err)
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("rendered %d configs, want %d", len(got), len(want))
|
||||
}
|
||||
for i := range want {
|
||||
if !reflect.DeepEqual(got[i], want[i]) {
|
||||
t.Errorf("config %d (%s) was not reproduced verbatim", i, rdStr(want[i]["remarks"]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderSingBoxKeepsRawOutbounds covers the sing-box side of the fidelity
|
||||
// rule using a document Prizma itself produced (no sing-box capture exists).
|
||||
func TestRenderSingBoxKeepsRawOutbounds(t *testing.T) {
|
||||
src := tstParse(t, "live_clash.yaml")
|
||||
first, _, err := Render(src, FormatSingBox, RenderOpts{})
|
||||
if err != nil {
|
||||
t.Fatalf("render singbox: %v", err)
|
||||
}
|
||||
nodes, format, err := Parse(first, "")
|
||||
if err != nil {
|
||||
t.Fatalf("parse singbox: %v", err)
|
||||
}
|
||||
if format != FormatSingBox {
|
||||
t.Fatalf("format = %q, want singbox", format)
|
||||
}
|
||||
second, _, err := Render(nodes, FormatSingBox, RenderOpts{})
|
||||
if err != nil {
|
||||
t.Fatalf("re-render singbox: %v", err)
|
||||
}
|
||||
if string(first) != string(second) {
|
||||
t.Error("sing-box -> sing-box is not stable; raw outbounds are being re-derived")
|
||||
}
|
||||
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal(second, &doc); err != nil {
|
||||
t.Fatalf("rendered sing-box is not valid JSON: %v", err)
|
||||
}
|
||||
tags := map[string]bool{}
|
||||
for _, o := range rdSlice(doc["outbounds"]) {
|
||||
tags[rdStr(rdMap(o)["tag"])] = true
|
||||
}
|
||||
for _, o := range rdSlice(doc["outbounds"]) {
|
||||
m := rdMap(o)
|
||||
switch rdStr(m["type"]) {
|
||||
case "selector", "urltest":
|
||||
list := rdSlice(m["outbounds"])
|
||||
if len(list) == 0 {
|
||||
t.Errorf("%s %q ended up empty", rdStr(m["type"]), rdStr(m["tag"]))
|
||||
}
|
||||
for _, ref := range list {
|
||||
if !tags[rdStr(ref)] {
|
||||
t.Errorf("%s %q references unknown tag %q", rdStr(m["type"]), rdStr(m["tag"]), rdStr(ref))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if final := rdStr(rdMap(doc["route"])["final"]); !tags[final] {
|
||||
t.Errorf("route.final = %q, which is not an outbound tag", final)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// merging behaviour a group depends on
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestRenderClashRewritesGroupsForSubset is the bug the contract calls out: a
|
||||
// reused Clash template still lists the upstream's original proxies, and a
|
||||
// group that filtered most of them away would ship a config every client
|
||||
// rejects.
|
||||
func TestRenderClashRewritesGroupsForSubset(t *testing.T) {
|
||||
src := tstParse(t, "live_clash.yaml")
|
||||
subset := []model.Node{src[0], src[5], src[31]}
|
||||
|
||||
body, _, err := Render(subset, FormatClash, RenderOpts{})
|
||||
if err != nil {
|
||||
t.Fatalf("render clash: %v", err)
|
||||
}
|
||||
var doc map[string]any
|
||||
if err := yaml.Unmarshal(body, &doc); err != nil {
|
||||
t.Fatalf("invalid YAML: %v", err)
|
||||
}
|
||||
if got := len(rdSlice(doc["proxies"])); got != len(subset) {
|
||||
t.Fatalf("rendered %d proxies, want %d", got, len(subset))
|
||||
}
|
||||
tstAssertClashRefs(t, doc)
|
||||
|
||||
// The surviving names must all still be selectable.
|
||||
referenced := map[string]bool{}
|
||||
for _, g := range rdSlice(doc["proxy-groups"]) {
|
||||
for _, ref := range rdSlice(rdMap(g)["proxies"]) {
|
||||
referenced[rdStr(ref)] = true
|
||||
}
|
||||
}
|
||||
for _, n := range subset {
|
||||
if !referenced[n.Name] {
|
||||
t.Errorf("node %q is not reachable from any proxy-group", n.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderClashAdoptsForeignNodes is the group case: the template came from
|
||||
// source A, but half the nodes come from source B and appear in no group of
|
||||
// that template. They must still be selectable.
|
||||
func TestRenderClashAdoptsForeignNodes(t *testing.T) {
|
||||
src := tstParse(t, "live_clash.yaml")
|
||||
|
||||
foreign := make([]model.Node, 0, 3)
|
||||
for _, n := range tstParse(t, "live_b64.txt")[:3] {
|
||||
c := n.Clone()
|
||||
c.Name = "[B] " + c.Name
|
||||
c.RawDoc = nil // a node from another source carries no Clash document
|
||||
foreign = append(foreign, c)
|
||||
}
|
||||
mixed := append(append([]model.Node{}, src[:2]...), foreign...)
|
||||
|
||||
body, _, err := Render(mixed, FormatClash, RenderOpts{})
|
||||
if err != nil {
|
||||
t.Fatalf("render clash: %v", err)
|
||||
}
|
||||
var doc map[string]any
|
||||
if err := yaml.Unmarshal(body, &doc); err != nil {
|
||||
t.Fatalf("invalid YAML: %v", err)
|
||||
}
|
||||
tstAssertClashRefs(t, doc)
|
||||
|
||||
referenced := map[string]bool{}
|
||||
for _, g := range rdSlice(doc["proxy-groups"]) {
|
||||
for _, ref := range rdSlice(rdMap(g)["proxies"]) {
|
||||
referenced[rdStr(ref)] = true
|
||||
}
|
||||
}
|
||||
for _, n := range mixed {
|
||||
if !referenced[n.Name] {
|
||||
t.Errorf("node %q is not reachable from any proxy-group", n.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// The same must hold when NOTHING the template listed survives.
|
||||
onlyForeign, _, err := Render(foreign, FormatClash, RenderOpts{Template: rdCopyMap(src[0].RawDoc)})
|
||||
if err != nil {
|
||||
t.Fatalf("render clash: %v", err)
|
||||
}
|
||||
if err := yaml.Unmarshal(onlyForeign, &doc); err != nil {
|
||||
t.Fatalf("invalid YAML: %v", err)
|
||||
}
|
||||
tstAssertClashRefs(t, doc)
|
||||
if got := len(rdSlice(doc["proxies"])); got != len(foreign) {
|
||||
t.Errorf("rendered %d proxies, want %d", got, len(foreign))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderRenamedNodes covers the one edit a renderer may make to a raw
|
||||
// payload: a group renamed the node, so the label must follow into every
|
||||
// format while the rest of the payload stays untouched.
|
||||
func TestRenderRenamedNodes(t *testing.T) {
|
||||
for _, tc := range tstCaptures {
|
||||
t.Run(tc.file, func(t *testing.T) {
|
||||
src := tstParse(t, tc.file)
|
||||
renamed := make([]model.Node, 0, len(src))
|
||||
want := make([]string, 0, len(src))
|
||||
for _, n := range src {
|
||||
c := n.Clone()
|
||||
c.Name = "[DE] " + c.Name
|
||||
renamed = append(renamed, c)
|
||||
want = append(want, c.Name)
|
||||
}
|
||||
|
||||
for _, f := range tstFormats {
|
||||
body, ct, err := Render(renamed, f, RenderOpts{})
|
||||
if err != nil {
|
||||
t.Fatalf("%s: render: %v", f, err)
|
||||
}
|
||||
back, _, err := Parse(body, ct)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: re-parse: %v", f, err)
|
||||
}
|
||||
if got := tstNames(back); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("%s: names not carried:\n got %q\nwant %q", f, got, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderDuplicateNamesStayUnique guards the Clash/sing-box invariant that a
|
||||
// proxy name is a reference target: two members of a group may legitimately
|
||||
// carry the same label.
|
||||
func TestRenderDuplicateNamesStayUnique(t *testing.T) {
|
||||
src := tstParse(t, "live_clash.yaml")
|
||||
dup := []model.Node{src[1].Clone(), src[2].Clone(), src[3].Clone()}
|
||||
for i := range dup {
|
||||
dup[i].Name = "Germany"
|
||||
}
|
||||
|
||||
body, _, err := Render(dup, FormatClash, RenderOpts{})
|
||||
if err != nil {
|
||||
t.Fatalf("render clash: %v", err)
|
||||
}
|
||||
var doc map[string]any
|
||||
if err := yaml.Unmarshal(body, &doc); err != nil {
|
||||
t.Fatalf("invalid YAML: %v", err)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, p := range rdSlice(doc["proxies"]) {
|
||||
name := rdStr(rdMap(p)["name"])
|
||||
if seen[name] {
|
||||
t.Errorf("duplicate proxy name %q survived into the document", name)
|
||||
}
|
||||
seen[name] = true
|
||||
}
|
||||
tstAssertClashRefs(t, doc)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// templates, empties and error paths
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestRenderUsesCallerTemplate(t *testing.T) {
|
||||
src := tstParse(t, "live_b64.txt") // URI nodes carry no RawDoc of their own
|
||||
|
||||
tpl := map[string]any{
|
||||
"mode": "rule",
|
||||
"proxies": []any{},
|
||||
"rules": []any{"DOMAIN-SUFFIX,example.com,DIRECT", "MATCH,PROXY"},
|
||||
"tun": map[string]any{"enable": true},
|
||||
"external": "keepme",
|
||||
}
|
||||
body, _, err := Render(src, FormatClash, RenderOpts{Template: tpl})
|
||||
if err != nil {
|
||||
t.Fatalf("render clash: %v", err)
|
||||
}
|
||||
var doc map[string]any
|
||||
if err := yaml.Unmarshal(body, &doc); err != nil {
|
||||
t.Fatalf("invalid YAML: %v", err)
|
||||
}
|
||||
if rdStr(doc["external"]) != "keepme" {
|
||||
t.Error("caller template block was dropped")
|
||||
}
|
||||
if len(rdSlice(doc["rules"])) != 2 {
|
||||
t.Error("caller rules were dropped")
|
||||
}
|
||||
if got := len(rdSlice(doc["proxies"])); got != tstNodeCount {
|
||||
t.Errorf("rendered %d proxies, want %d", got, tstNodeCount)
|
||||
}
|
||||
tstAssertClashRefs(t, doc)
|
||||
|
||||
// The template must not be mutated: it is shared with the fetch cache.
|
||||
if len(rdSlice(tpl["proxies"])) != 0 {
|
||||
t.Error("Render mutated the caller's template")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderTemplateOfWrongFamilyIsIgnored(t *testing.T) {
|
||||
src := tstParse(t, "live_b64.txt")
|
||||
clashTemplate := map[string]any{"proxies": []any{}, "rules": []any{"MATCH,PROXY"}}
|
||||
|
||||
body, _, err := Render(src, FormatSingBox, RenderOpts{Template: clashTemplate})
|
||||
if err != nil {
|
||||
t.Fatalf("render singbox: %v", err)
|
||||
}
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal(body, &doc); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
if _, ok := doc["rules"]; ok {
|
||||
t.Error("a Clash template leaked into a sing-box document")
|
||||
}
|
||||
if len(rdSlice(doc["outbounds"])) < tstNodeCount {
|
||||
t.Error("outbounds missing from the sing-box document")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderEmptySubscription(t *testing.T) {
|
||||
for _, f := range tstFormats {
|
||||
body, ct, err := Render(nil, f, RenderOpts{})
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", f, err)
|
||||
}
|
||||
if ct == "" {
|
||||
t.Errorf("%s: no content type", f)
|
||||
}
|
||||
nodes, _, err := Parse(body, ct)
|
||||
if err != nil && f != FormatURIList && f != FormatBase64 {
|
||||
t.Errorf("%s: empty render does not re-parse: %v", f, err)
|
||||
}
|
||||
if len(nodes) != 0 {
|
||||
t.Errorf("%s: empty render produced %d nodes", f, len(nodes))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderUnknownFormatFallsBackToBase64(t *testing.T) {
|
||||
src := tstParse(t, "live_b64.txt")
|
||||
body, ct, err := Render(src, FormatUnknown, RenderOpts{})
|
||||
if err != nil {
|
||||
t.Fatalf("render unknown: %v", err)
|
||||
}
|
||||
if ct != ContentTypeFor(FormatBase64) {
|
||||
t.Errorf("content type = %q", ct)
|
||||
}
|
||||
if got := Detect(body, ct); got != FormatBase64 {
|
||||
t.Errorf("fallback rendered %q, want base64", got)
|
||||
}
|
||||
if _, _, err := Render(src, Format("nonsense"), RenderOpts{}); err == nil {
|
||||
t.Error("Render accepted a format it cannot produce")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// cross-format derivation of protocols the captures do not contain
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestDeriveEveryProtocol builds nodes with no raw payload at all — the state a
|
||||
// node reaches after being converted between two formats — and checks each
|
||||
// protocol survives a URI round trip.
|
||||
func TestDeriveEveryProtocol(t *testing.T) {
|
||||
nodes := []model.Node{
|
||||
{Name: "vless-ws", Protocol: model.ProtoVLESS, Server: "a.example.com", Port: 443,
|
||||
UUID: "123d3129-06dc-4210-8087-8cde41885482", Network: "ws", Path: "/ws", Host: "cdn.example.com",
|
||||
Security: "tls", SNI: "a.example.com", Fingerprint: "chrome", ALPN: []string{"h2", "http/1.1"}},
|
||||
{Name: "vless-reality", Protocol: model.ProtoVLESS, Server: "b.example.com", Port: 443,
|
||||
UUID: "123d3129-06dc-4210-8087-8cde41885482", Network: "tcp", Flow: "xtls-rprx-vision",
|
||||
Security: "reality", SNI: "www.microsoft.com", PublicKey: "abcdefg_hijklmn", ShortID: "43cf868e", Fingerprint: "firefox"},
|
||||
{Name: "vmess-grpc", Protocol: model.ProtoVMess, Server: "c.example.com", Port: 8443,
|
||||
UUID: "123d3129-06dc-4210-8087-8cde41885482", AlterID: 0, Method: "auto",
|
||||
Network: "grpc", ServiceName: "grpcsvc", Security: "tls", SNI: "c.example.com"},
|
||||
{Name: "trojan", Protocol: model.ProtoTrojan, Server: "d.example.com", Port: 443,
|
||||
Password: "p@ss word", Security: "tls", SNI: "d.example.com", Network: "tcp"},
|
||||
{Name: "ss", Protocol: model.ProtoShadowsock, Server: "e.example.com", Port: 8388,
|
||||
Method: "chacha20-ietf-poly1305", Password: "Nhmr6uzxbPPA_sLPcQMF9wqHLB4bCzbm"},
|
||||
{Name: "hy2", Protocol: model.ProtoHysteria2, Server: "f.example.com", Port: 443,
|
||||
Password: "secret", SNI: "f.example.com", Obfs: "salamander", ObfsPassword: "obfspass"},
|
||||
{Name: "hysteria", Protocol: model.ProtoHysteria, Server: "g.example.com", Port: 443,
|
||||
Password: "auth-token", SNI: "g.example.com", UpMbps: 50, DownMbps: 100},
|
||||
{Name: "tuic", Protocol: model.ProtoTUIC, Server: "h.example.com", Port: 443,
|
||||
UUID: "123d3129-06dc-4210-8087-8cde41885482", Password: "tuicpass",
|
||||
SNI: "h.example.com", Congestion: "bbr"},
|
||||
{Name: "anytls", Protocol: model.ProtoAnyTLS, Server: "i.example.com", Port: 443,
|
||||
Password: "anytlspass", SNI: "i.example.com"},
|
||||
{Name: "socks", Protocol: model.ProtoSOCKS, Server: "j.example.com", Port: 1080,
|
||||
Password: "sockspass", Extra: map[string]any{"username": "user"}},
|
||||
{Name: "http", Protocol: model.ProtoHTTP, Server: "k.example.com", Port: 8080,
|
||||
Password: "httppass", Extra: map[string]any{"username": "user"}},
|
||||
{Name: "ssr", Protocol: model.ProtoSSR, Server: "l.example.com", Port: 8388,
|
||||
Method: "aes-256-cfb", Password: "ssrpass", Obfs: "plain",
|
||||
Extra: map[string]any{"protocol": "auth_aes128_md5"}},
|
||||
{Name: "wireguard", Protocol: model.ProtoWireGuard, Server: "m.example.com", Port: 51820,
|
||||
PublicKey: "pub_key_value", Extra: map[string]any{
|
||||
"private_key": "priv_key_value", "address": []string{"10.0.0.2/32"}, "mtu": 1420}},
|
||||
}
|
||||
|
||||
body, ct, err := Render(nodes, FormatURIList, RenderOpts{})
|
||||
if err != nil {
|
||||
t.Fatalf("render uri: %v", err)
|
||||
}
|
||||
lines := strings.Split(string(body), "\n")
|
||||
if len(lines) != len(nodes) {
|
||||
t.Fatalf("rendered %d URIs, want %d:\n%s", len(lines), len(nodes), body)
|
||||
}
|
||||
|
||||
back, _, err := Parse(body, ct)
|
||||
if err != nil {
|
||||
t.Fatalf("re-parse: %v", err)
|
||||
}
|
||||
if len(back) != len(nodes) {
|
||||
t.Fatalf("re-parsed %d nodes, want %d", len(back), len(nodes))
|
||||
}
|
||||
for i, n := range back {
|
||||
want := nodes[i]
|
||||
if n.Name != want.Name {
|
||||
t.Errorf("%s: name = %q", want.Name, n.Name)
|
||||
}
|
||||
if n.Protocol != want.Protocol {
|
||||
t.Errorf("%s: protocol = %q, want %q (uri %s)", want.Name, n.Protocol, want.Protocol, lines[i])
|
||||
}
|
||||
if n.Server != want.Server || n.Port != want.Port {
|
||||
t.Errorf("%s: endpoint = %s:%d, want %s:%d (uri %s)",
|
||||
want.Name, n.Server, n.Port, want.Server, want.Port, lines[i])
|
||||
}
|
||||
if want.UUID != "" && n.UUID != want.UUID {
|
||||
t.Errorf("%s: uuid = %q, want %q", want.Name, n.UUID, want.UUID)
|
||||
}
|
||||
if want.Password != "" && n.Password != want.Password && n.UUID != want.Password {
|
||||
t.Errorf("%s: password = %q, want %q (uri %s)", want.Name, n.Password, want.Password, lines[i])
|
||||
}
|
||||
}
|
||||
|
||||
// The same nodes must also survive the document formats.
|
||||
for _, f := range []Format{FormatClash, FormatSingBox, FormatXray} {
|
||||
body, ct, err := Render(nodes, f, RenderOpts{})
|
||||
if err != nil {
|
||||
t.Fatalf("%s: render: %v", f, err)
|
||||
}
|
||||
back, _, err := Parse(body, ct)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: re-parse: %v", f, err)
|
||||
}
|
||||
if len(back) != len(nodes) {
|
||||
t.Errorf("%s: %d nodes survived, want %d (names %q)", f, len(back), len(nodes), tstNames(back))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeriveEveryTransport walks the transports a vless node can use. Xray and
|
||||
// the URI list can express all of them, so both must carry the network back.
|
||||
func TestDeriveEveryTransport(t *testing.T) {
|
||||
base := model.Node{
|
||||
Protocol: model.ProtoVLESS, Server: "t.example.com", Port: 443,
|
||||
UUID: "123d3129-06dc-4210-8087-8cde41885482", Security: "tls", SNI: "t.example.com",
|
||||
}
|
||||
cases := []model.Node{
|
||||
func() model.Node { n := base; n.Name = "tcp"; n.Network = "tcp"; return n }(),
|
||||
func() model.Node {
|
||||
n := base
|
||||
n.Name, n.Network, n.HeaderType, n.Path, n.Host = "tcp-http", "tcp", "http", "/dl", "fake.example.com"
|
||||
return n
|
||||
}(),
|
||||
func() model.Node {
|
||||
n := base
|
||||
n.Name, n.Network, n.Path, n.Host = "ws", "ws", "/ws", "cdn.example.com"
|
||||
return n
|
||||
}(),
|
||||
func() model.Node {
|
||||
n := base
|
||||
n.Name, n.Network, n.ServiceName = "grpc", "grpc", "gsvc"
|
||||
return n
|
||||
}(),
|
||||
func() model.Node {
|
||||
n := base
|
||||
n.Name, n.Network, n.Path, n.Host = "h2", "h2", "/h2", "h2.example.com"
|
||||
return n
|
||||
}(),
|
||||
func() model.Node {
|
||||
n := base
|
||||
n.Name, n.Network, n.Path, n.Host = "httpupgrade", "httpupgrade", "/hu", "hu.example.com"
|
||||
return n
|
||||
}(),
|
||||
func() model.Node {
|
||||
n := base
|
||||
n.Name, n.Network, n.Path = "xhttp", "xhttp", "/xh"
|
||||
return n
|
||||
}(),
|
||||
func() model.Node {
|
||||
n := base
|
||||
n.Name, n.Network, n.Seed, n.HeaderType = "kcp", "kcp", "seedvalue", "wechat-video"
|
||||
return n
|
||||
}(),
|
||||
func() model.Node { n := base; n.Name, n.Network = "quic", "quic"; return n }(),
|
||||
}
|
||||
|
||||
for _, f := range []Format{FormatURIList, FormatXray} {
|
||||
body, ct, err := Render(cases, f, RenderOpts{})
|
||||
if err != nil {
|
||||
t.Fatalf("%s: render: %v", f, err)
|
||||
}
|
||||
back, _, err := Parse(body, ct)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: re-parse: %v", f, err)
|
||||
}
|
||||
if len(back) != len(cases) {
|
||||
t.Fatalf("%s: %d nodes survived, want %d (%q)", f, len(back), len(cases), tstNames(back))
|
||||
}
|
||||
for i, n := range back {
|
||||
if n.Name != cases[i].Name {
|
||||
t.Errorf("%s: node %d name = %q, want %q", f, i, n.Name, cases[i].Name)
|
||||
}
|
||||
if n.Network != cases[i].Network {
|
||||
t.Errorf("%s: %s: network = %q, want %q", f, cases[i].Name, n.Network, cases[i].Network)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clash and sing-box only model a subset; they must still keep every node.
|
||||
for _, f := range []Format{FormatClash, FormatSingBox} {
|
||||
body, ct, err := Render(cases, f, RenderOpts{})
|
||||
if err != nil {
|
||||
t.Fatalf("%s: render: %v", f, err)
|
||||
}
|
||||
back, _, err := Parse(body, ct)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: re-parse: %v", f, err)
|
||||
}
|
||||
if len(back) != len(cases) {
|
||||
t.Errorf("%s: %d nodes survived, want %d (%q)", f, len(back), len(cases), tstNames(back))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderSkipsUnrepresentableNodes: a node that no URI can express must not
|
||||
// take the rest of the subscription down with it.
|
||||
func TestRenderSkipsUnrepresentableNodes(t *testing.T) {
|
||||
nodes := []model.Node{
|
||||
{Name: "broken", Protocol: "vless"}, // no server
|
||||
{Name: "good", Protocol: model.ProtoShadowsock, Server: "e.example.com", Port: 8388,
|
||||
Method: "chacha20-ietf-poly1305", Password: "pw"},
|
||||
}
|
||||
body, ct, err := Render(nodes, FormatURIList, RenderOpts{})
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
back, _, err := Parse(body, ct)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if len(back) != 1 || back[0].Name != "good" {
|
||||
t.Fatalf("got %q, want just the good node", tstNames(back))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRetagLabelsInsideThePayload covers the two schemes that do not keep their
|
||||
// label in the fragment: vmess hides it in the base64 JSON and ssr in a base64
|
||||
// query parameter. Renaming a node in a group must reach both.
|
||||
func TestRetagLabelsInsideThePayload(t *testing.T) {
|
||||
vmessJSON := `{"v":"2","ps":"Old vmess","add":"vm.example.com","port":"443",` +
|
||||
`"id":"123d3129-06dc-4210-8087-8cde41885482","aid":"0","scy":"auto","net":"ws",` +
|
||||
`"type":"none","host":"cdn.example.com","path":"/path","tls":"tls","sni":"vm.example.com"}`
|
||||
b64url := base64.URLEncoding.WithPadding(base64.NoPadding)
|
||||
ssrBody := "ssr.example.com:8388:auth_aes128_md5:aes-256-cfb:plain:" +
|
||||
b64url.EncodeToString([]byte("ssrpass")) +
|
||||
"/?remarks=" + b64url.EncodeToString([]byte("Old ssr"))
|
||||
|
||||
body := strings.Join([]string{
|
||||
"vmess://" + base64.StdEncoding.EncodeToString([]byte(vmessJSON)),
|
||||
"ssr://" + b64url.EncodeToString([]byte(ssrBody)),
|
||||
}, "\n")
|
||||
|
||||
nodes, _, err := Parse([]byte(body), "")
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if len(nodes) != 2 {
|
||||
t.Fatalf("parsed %d nodes, want 2 (%q)", len(nodes), tstNames(nodes))
|
||||
}
|
||||
if got := tstNames(nodes); !reflect.DeepEqual(got, []string{"Old vmess", "Old ssr"}) {
|
||||
t.Fatalf("names = %q", got)
|
||||
}
|
||||
|
||||
// Unchanged names: the payload must come back untouched.
|
||||
same, _, err := Render(nodes, FormatURIList, RenderOpts{})
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
if string(same) != body {
|
||||
t.Errorf("unrenamed payload was rewritten:\n got %s\nwant %s", same, body)
|
||||
}
|
||||
|
||||
for i := range nodes {
|
||||
nodes[i].Name = "NEW " + nodes[i].Name
|
||||
}
|
||||
out, ct, err := Render(nodes, FormatURIList, RenderOpts{})
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
if strings.Contains(string(out), "#") {
|
||||
t.Errorf("a label was appended as a fragment instead of rewritten in place:\n%s", out)
|
||||
}
|
||||
back, _, err := Parse(out, ct)
|
||||
if err != nil {
|
||||
t.Fatalf("re-parse: %v", err)
|
||||
}
|
||||
want := []string{"NEW Old vmess", "NEW Old ssr"}
|
||||
if got := tstNames(back); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("names = %q, want %q", got, want)
|
||||
}
|
||||
if back[0].Server != "vm.example.com" || back[0].Path != "/path" {
|
||||
t.Errorf("vmess payload damaged by the rename: %+v", back[0])
|
||||
}
|
||||
if back[1].Server != "ssr.example.com" || back[1].Port != 8388 {
|
||||
t.Errorf("ssr payload damaged by the rename: %+v", back[1])
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// negotiation helpers used by the public subscription handler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestFormatForUA(t *testing.T) {
|
||||
cases := map[string]Format{
|
||||
"clash-verge/1.7.7": FormatClash,
|
||||
"Stash/2.5 Clash/1.0": FormatClash,
|
||||
"mihomo/1.18": FormatClash,
|
||||
"sing-box 1.9.0": FormatSingBox,
|
||||
"Hiddify/2.0": FormatSingBox,
|
||||
"Happ/1.2": FormatBase64,
|
||||
"v2rayNG/1.8.5": FormatBase64,
|
||||
"Shadowrocket/2.2.30": FormatBase64,
|
||||
"Streisand/1.6": FormatBase64,
|
||||
"": FormatBase64,
|
||||
"curl/8.4.0": FormatBase64,
|
||||
"NekoBox/1.2 (Prefer)": FormatBase64,
|
||||
}
|
||||
for ua, want := range cases {
|
||||
if got := FormatForUA(ua); got != want {
|
||||
t.Errorf("FormatForUA(%q) = %q, want %q", ua, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFormatAliases(t *testing.T) {
|
||||
cases := map[string]Format{
|
||||
"clash": FormatClash, "CLASH": FormatClash, " yaml ": FormatClash,
|
||||
"base64": FormatBase64, "b64": FormatBase64,
|
||||
"uri": FormatURIList, "links": FormatURIList,
|
||||
"sing-box": FormatSingBox, "singbox": FormatSingBox,
|
||||
"xray": FormatXray, "happ": FormatXray, "json": FormatXray,
|
||||
}
|
||||
for in, want := range cases {
|
||||
got, ok := ParseFormat(in)
|
||||
if !ok || got != want {
|
||||
t.Errorf("ParseFormat(%q) = %q,%v; want %q,true", in, got, ok, want)
|
||||
}
|
||||
}
|
||||
for _, in := range []string{"", " ", "protobuf", "surge-conf"} {
|
||||
if got, ok := ParseFormat(in); ok {
|
||||
t.Errorf("ParseFormat(%q) = %q,true; want false", in, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBase64OfDocumentsIsDetected covers the robustness rule: an upstream may
|
||||
// base64 anything, including a Clash YAML or a JSON config.
|
||||
func TestBase64OfDocumentsIsDetected(t *testing.T) {
|
||||
for _, tc := range tstCaptures[1:] { // the b64 capture is already base64
|
||||
body := tstLoad(t, tc.file)
|
||||
for _, enc := range []*base64.Encoding{base64.StdEncoding, base64.RawURLEncoding} {
|
||||
wrapped := []byte(enc.EncodeToString(body))
|
||||
nodes, _, err := Parse(wrapped, "")
|
||||
if err != nil {
|
||||
t.Fatalf("%s wrapped in base64: %v", tc.file, err)
|
||||
}
|
||||
if len(nodes) != tstNodeCount {
|
||||
t.Errorf("%s wrapped in base64: %d nodes, want %d", tc.file, len(nodes), tstNodeCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+28
@@ -0,0 +1,28 @@
|
||||
# Fixtures
|
||||
|
||||
Four captures of **one real subscription**, taken from a live Remnawave-style
|
||||
panel with four different `User-Agent` values — which is the whole point: the
|
||||
same upstream link answers in a different format depending on who asks.
|
||||
|
||||
| file | format | shape |
|
||||
|---|---|---|
|
||||
| `live_happ.json` | `xray` | Happ-style JSON **array of 34 whole Xray configs**, each with `remarks` + `outbounds` |
|
||||
| `live_v2rayn.txt` | `xray` | what v2rayN gets — also a Happ-style array, despite the `.txt` |
|
||||
| `live_clash.yaml` | `clash` | Clash YAML, 34 proxies (29 vless, 4 ss, 1 hysteria2) |
|
||||
| `live_b64.txt` | `base64` | base64 of a 34-line URI list |
|
||||
|
||||
All four describe the same 34 nodes with the same names in the same order,
|
||||
which is what makes them useful: parse any one of them, render it as any other,
|
||||
and the node list must survive.
|
||||
|
||||
## These are anonymized
|
||||
|
||||
The capture came from a paid account, so every credential and address was
|
||||
replaced with a deterministic fake before the files were committed: UUIDs and
|
||||
passwords, Reality public keys and short IDs, transport paths, hostnames
|
||||
(`nodeN.example.net`, RFC 2606) and IP addresses (`198.51.100.x`, RFC 5737).
|
||||
|
||||
Everything the parsers and renderers are tested on is untouched — structure,
|
||||
formats, node count, ordering, names, protocol mix, transport and TLS shapes.
|
||||
Nothing here connects to a real server, so do not expect these configs to work
|
||||
if you paste them into a client.
|
||||
+1
File diff suppressed because one or more lines are too long
+590
@@ -0,0 +1,590 @@
|
||||
mixed-port: 7890
|
||||
socks-port: 7891
|
||||
redir-port: 7892
|
||||
allow-lan: true
|
||||
mode: global
|
||||
log-level: info
|
||||
external-controller: 127.0.0.1:9090
|
||||
dns:
|
||||
enable: true
|
||||
use-hosts: true
|
||||
enhanced-mode: fake-ip
|
||||
fake-ip-range: 198.18.0.1/16
|
||||
default-nameserver:
|
||||
- 1.1.1.1
|
||||
- 8.8.8.8
|
||||
nameserver:
|
||||
- 1.1.1.1
|
||||
- 8.8.8.8
|
||||
fake-ip-filter:
|
||||
- "*.lan"
|
||||
- stun.*.*.*
|
||||
- stun.*.*
|
||||
- time.windows.com
|
||||
- time.nist.gov
|
||||
- time.apple.com
|
||||
- time.asia.apple.com
|
||||
- "*.openwrt.pool.ntp.org"
|
||||
- pool.ntp.org
|
||||
- ntp.ubuntu.com
|
||||
- time1.apple.com
|
||||
- time2.apple.com
|
||||
- time3.apple.com
|
||||
- time4.apple.com
|
||||
- time5.apple.com
|
||||
- time6.apple.com
|
||||
- time7.apple.com
|
||||
- time1.google.com
|
||||
- time2.google.com
|
||||
- time3.google.com
|
||||
- time4.google.com
|
||||
- api.joox.com
|
||||
- joox.com
|
||||
- "*.xiami.com"
|
||||
- "*.msftconnecttest.com"
|
||||
- "*.msftncsi.com"
|
||||
- +.xboxlive.com
|
||||
- "*.*.stun.playstation.net"
|
||||
- xbox.*.*.microsoft.com
|
||||
- "*.ipv6.microsoft.com"
|
||||
- speedtest.cros.wr.pvp.net
|
||||
proxies:
|
||||
- name: АВТО ВЫБОР ( ЛУЧШИЙ )
|
||||
type: ss
|
||||
server: node22.example.net
|
||||
port: 1234
|
||||
network: tcp
|
||||
udp: true
|
||||
password: bt7klOyzaoi3EAVfqKfDwyWAXzLC-pwA
|
||||
cipher: chacha20-ietf-poly1305
|
||||
udp-over-tcp: false
|
||||
udp-over-tcp-version: 1
|
||||
client-fingerprint: chrome
|
||||
- name: 🇺🇸 США
|
||||
type: vless
|
||||
server: node10.example.net
|
||||
port: 443
|
||||
network: tcp
|
||||
udp: true
|
||||
uuid: bd83cb66-b2d8-48e3-74f7-de34489f9b52
|
||||
packet-encoding: xudp
|
||||
flow: xtls-rprx-vision
|
||||
tls: true
|
||||
servername: node10.example.net
|
||||
reality-opts:
|
||||
public-key: x7b0Xn8k0zNB7iuRcYfU-BniBRsofrTyKhBLJBuBy3Y
|
||||
short-id: ""
|
||||
client-fingerprint: firefox
|
||||
- name: 🇱🇹 Литва
|
||||
type: ss
|
||||
server: node22.example.net
|
||||
port: 1234
|
||||
network: tcp
|
||||
udp: true
|
||||
password: bt7klOyzaoi3EAVfqKfDwyWAXzLC-pwA
|
||||
cipher: chacha20-ietf-poly1305
|
||||
udp-over-tcp: false
|
||||
udp-over-tcp-version: 1
|
||||
client-fingerprint: chrome
|
||||
- name: 🇦🇪 Дубай
|
||||
type: vless
|
||||
server: node7.example.net
|
||||
port: 443
|
||||
network: tcp
|
||||
udp: true
|
||||
uuid: bd83cb66-b2d8-48e3-74f7-de34489f9b52
|
||||
packet-encoding: xudp
|
||||
flow: xtls-rprx-vision
|
||||
tls: true
|
||||
servername: node7.example.net
|
||||
reality-opts:
|
||||
public-key: QCsgzdUyhBdrK24LWiW-oitTr9KZJ6kug2D5yDQPfi4
|
||||
short-id: 33bfaf3d682f3794
|
||||
client-fingerprint: firefox
|
||||
- name: 🇷🇺 Россия
|
||||
type: vless
|
||||
server: node11.example.net
|
||||
port: 443
|
||||
network: tcp
|
||||
udp: true
|
||||
uuid: bd83cb66-b2d8-48e3-74f7-de34489f9b52
|
||||
packet-encoding: xudp
|
||||
flow: xtls-rprx-vision
|
||||
tls: true
|
||||
servername: node11.example.net
|
||||
reality-opts:
|
||||
public-key: oMrUOEgh6EUgywTwg1ppVOMgYeu_6ZpU9ERWawnx_VM
|
||||
short-id: ""
|
||||
client-fingerprint: firefox
|
||||
- name: 🇨🇦 Канада
|
||||
type: ss
|
||||
server: node22.example.net
|
||||
port: 1234
|
||||
network: tcp
|
||||
udp: true
|
||||
password: bt7klOyzaoi3EAVfqKfDwyWAXzLC-pwA
|
||||
cipher: chacha20-ietf-poly1305
|
||||
udp-over-tcp: false
|
||||
udp-over-tcp-version: 1
|
||||
client-fingerprint: chrome
|
||||
- name: " 🇸🇪 Швеция"
|
||||
type: vless
|
||||
server: node15.example.net
|
||||
port: 443
|
||||
network: tcp
|
||||
udp: true
|
||||
uuid: bd83cb66-b2d8-48e3-74f7-de34489f9b52
|
||||
packet-encoding: xudp
|
||||
flow: xtls-rprx-vision
|
||||
tls: true
|
||||
servername: node15.example.net
|
||||
reality-opts:
|
||||
public-key: r2KJ7QsOrNAAO1OMlRppCxV2Bt1PSpLqBbY2cQQ-wKs
|
||||
short-id: ""
|
||||
client-fingerprint: firefox
|
||||
- name: 🇭🇰 Гонконг
|
||||
type: vless
|
||||
server: 198.51.100.13
|
||||
port: 443
|
||||
network: grpc
|
||||
udp: true
|
||||
uuid: bd83cb66-b2d8-48e3-74f7-de34489f9b52
|
||||
packet-encoding: xudp
|
||||
tls: true
|
||||
servername: node21.example.net
|
||||
reality-opts:
|
||||
public-key: pKz9a_G0ecB1Rw4NKsNDF7IEmWmeqXmxv_Cvhcu-SO8
|
||||
short-id: ""
|
||||
grpc-opts:
|
||||
grpc-service-name: ns
|
||||
client-fingerprint: firefox
|
||||
- name: 🇵🇱 Польша
|
||||
type: vless
|
||||
server: 198.51.100.15
|
||||
port: 443
|
||||
network: grpc
|
||||
udp: true
|
||||
uuid: bd83cb66-b2d8-48e3-74f7-de34489f9b52
|
||||
packet-encoding: xudp
|
||||
tls: true
|
||||
servername: node21.example.net
|
||||
reality-opts:
|
||||
public-key: pKz9a_G0ecB1Rw4NKsNDF7IEmWmeqXmxv_Cvhcu-SO8
|
||||
short-id: ""
|
||||
grpc-opts:
|
||||
grpc-service-name: ns
|
||||
client-fingerprint: firefox
|
||||
- name: 🇵🇱 Польша 2
|
||||
type: vless
|
||||
server: node14.example.net
|
||||
port: 443
|
||||
network: tcp
|
||||
udp: true
|
||||
uuid: bd83cb66-b2d8-48e3-74f7-de34489f9b52
|
||||
packet-encoding: xudp
|
||||
flow: xtls-rprx-vision
|
||||
tls: true
|
||||
servername: node14.example.net
|
||||
reality-opts:
|
||||
public-key: MOOShuxzTL_vGYxHoAOdG26FCECajKyAaKGS8OM5vvg
|
||||
short-id: ""
|
||||
client-fingerprint: firefox
|
||||
- name: 🇪🇪 Эстония
|
||||
type: vless
|
||||
server: node18.example.net
|
||||
port: 443
|
||||
network: tcp
|
||||
udp: true
|
||||
uuid: bd83cb66-b2d8-48e3-74f7-de34489f9b52
|
||||
packet-encoding: xudp
|
||||
flow: xtls-rprx-vision
|
||||
tls: true
|
||||
servername: node18.example.net
|
||||
reality-opts:
|
||||
public-key: _nLZ3pfL6yPxX_4WDJ7D-aY8FDVQMquuk1i8T-dAwZU
|
||||
short-id: ""
|
||||
client-fingerprint: firefox
|
||||
- name: 🇫🇷 Франция
|
||||
type: vless
|
||||
server: node16.example.net
|
||||
port: 443
|
||||
network: tcp
|
||||
udp: true
|
||||
uuid: bd83cb66-b2d8-48e3-74f7-de34489f9b52
|
||||
packet-encoding: xudp
|
||||
flow: xtls-rprx-vision
|
||||
tls: true
|
||||
servername: node16.example.net
|
||||
reality-opts:
|
||||
public-key: 3WJiq4_vT6oM8Fg5FVxTAP7E9LpaAuzsJR2RohSj0uw
|
||||
short-id: ""
|
||||
client-fingerprint: firefox
|
||||
- name: 🇸🇬 Сингапур
|
||||
type: vless
|
||||
server: node13.example.net
|
||||
port: 443
|
||||
network: tcp
|
||||
udp: true
|
||||
uuid: bd83cb66-b2d8-48e3-74f7-de34489f9b52
|
||||
packet-encoding: xudp
|
||||
flow: xtls-rprx-vision
|
||||
tls: true
|
||||
servername: node13.example.net
|
||||
reality-opts:
|
||||
public-key: vf_QGGMfz-Ka0tlqJVyDJ-DqA5K1et20r29TLWX8Dy8
|
||||
short-id: ""
|
||||
client-fingerprint: firefox
|
||||
- name: 🇩🇪 Германия
|
||||
type: vless
|
||||
server: node17.example.net
|
||||
port: 443
|
||||
network: tcp
|
||||
udp: true
|
||||
uuid: bd83cb66-b2d8-48e3-74f7-de34489f9b52
|
||||
packet-encoding: xudp
|
||||
flow: xtls-rprx-vision
|
||||
tls: true
|
||||
servername: node17.example.net
|
||||
reality-opts:
|
||||
public-key: YDl3DOQ2Pet1m4M-oRKgDmuL1dk7NCTq2MI0hvLRca0
|
||||
short-id: ""
|
||||
client-fingerprint: firefox
|
||||
- name: 🇧🇷 Бразилия
|
||||
type: vless
|
||||
server: node12.example.net
|
||||
port: 443
|
||||
network: tcp
|
||||
udp: true
|
||||
uuid: bd83cb66-b2d8-48e3-74f7-de34489f9b52
|
||||
packet-encoding: xudp
|
||||
flow: xtls-rprx-vision
|
||||
tls: true
|
||||
servername: node12.example.net
|
||||
reality-opts:
|
||||
public-key: bnuDfhI3cc1tJJmQkGCJ8OgA3AEmwGAkWxeXKd1ZGzU
|
||||
short-id: ""
|
||||
client-fingerprint: firefox
|
||||
- name: 🇰🇿 Казахстан
|
||||
type: vless
|
||||
server: node8.example.net
|
||||
port: 443
|
||||
network: tcp
|
||||
udp: true
|
||||
uuid: bd83cb66-b2d8-48e3-74f7-de34489f9b52
|
||||
packet-encoding: xudp
|
||||
flow: xtls-rprx-vision
|
||||
tls: true
|
||||
servername: node8.example.net
|
||||
reality-opts:
|
||||
public-key: NxgMgo6mGrJM1pJplIPr2uCBiQfc9lXeTx--fITqxDE
|
||||
short-id: ""
|
||||
client-fingerprint: random
|
||||
- name: 🇫🇮 Финляндия
|
||||
type: vless
|
||||
server: node5.example.net
|
||||
port: 443
|
||||
network: tcp
|
||||
udp: true
|
||||
uuid: bd83cb66-b2d8-48e3-74f7-de34489f9b52
|
||||
packet-encoding: xudp
|
||||
flow: xtls-rprx-vision
|
||||
tls: true
|
||||
servername: node5.example.net
|
||||
reality-opts:
|
||||
public-key: h_AnVl2u1baIOpFJb-31ZDfQrCWI3Rt5SLjrDWp53bg
|
||||
short-id: ""
|
||||
client-fingerprint: firefox
|
||||
- name: 🇳🇱 Нидерланды
|
||||
type: ss
|
||||
server: node22.example.net
|
||||
port: 1234
|
||||
network: tcp
|
||||
udp: true
|
||||
password: bt7klOyzaoi3EAVfqKfDwyWAXzLC-pwA
|
||||
cipher: chacha20-ietf-poly1305
|
||||
udp-over-tcp: false
|
||||
udp-over-tcp-version: 1
|
||||
client-fingerprint: chrome
|
||||
- name: 🇸🇴 АВТО ОБХОД ( БС ) 🐾
|
||||
type: vless
|
||||
server: 198.51.100.17
|
||||
port: 443
|
||||
network: tcp
|
||||
udp: true
|
||||
uuid: bd83cb66-b2d8-48e3-74f7-de34489f9b52
|
||||
packet-encoding: xudp
|
||||
flow: xtls-rprx-vision
|
||||
tls: true
|
||||
servername: node23.example.net
|
||||
reality-opts:
|
||||
public-key: URMM50Y7LxH7CKbIiFzqqM5dw-MvzQRNfFyq1c3oUhA
|
||||
short-id: ""
|
||||
client-fingerprint: firefox
|
||||
- name: 🇸🇴 ОБХОД № 1 🐾
|
||||
type: vless
|
||||
server: 198.51.100.19
|
||||
port: 443
|
||||
network: grpc
|
||||
udp: true
|
||||
uuid: bd83cb66-b2d8-48e3-74f7-de34489f9b52
|
||||
packet-encoding: xudp
|
||||
tls: true
|
||||
servername: node23.example.net
|
||||
reality-opts:
|
||||
public-key: 9vjOrA0zirEzJCMLa0oWHrEtggRoESHiAmVf8rbgfuI
|
||||
short-id: ab952529e442f515
|
||||
grpc-opts:
|
||||
grpc-service-name: ""
|
||||
client-fingerprint: firefox
|
||||
- name: 🇸🇴 ОБХОД № 2 🐾
|
||||
type: vless
|
||||
server: node3.example.net
|
||||
port: 443
|
||||
network: tcp
|
||||
udp: true
|
||||
uuid: bd83cb66-b2d8-48e3-74f7-de34489f9b52
|
||||
packet-encoding: xudp
|
||||
flow: xtls-rprx-vision
|
||||
tls: true
|
||||
servername: node23.example.net
|
||||
reality-opts:
|
||||
public-key: XopUQFOToiHVbiZ6atJ10zoSox23wgHTQedTa3JSF0g
|
||||
short-id: ""
|
||||
client-fingerprint: firefox
|
||||
- name: 🇸🇴 ОБХОД № 3 🐾
|
||||
type: vless
|
||||
server: 198.51.100.21
|
||||
port: 443
|
||||
network: grpc
|
||||
udp: true
|
||||
uuid: bd83cb66-b2d8-48e3-74f7-de34489f9b52
|
||||
packet-encoding: xudp
|
||||
tls: true
|
||||
servername: node23.example.net
|
||||
reality-opts:
|
||||
public-key: 9vjOrA0zirEzJCMLa0oWHrEtggRoESHiAmVf8rbgfuI
|
||||
short-id: ab952529e442f515
|
||||
grpc-opts:
|
||||
grpc-service-name: ""
|
||||
client-fingerprint: firefox
|
||||
- name: 🇸🇴 ОБХОД № 4 🐾
|
||||
type: vless
|
||||
server: node6.example.net
|
||||
port: 443
|
||||
network: tcp
|
||||
udp: true
|
||||
uuid: bd83cb66-b2d8-48e3-74f7-de34489f9b52
|
||||
packet-encoding: xudp
|
||||
flow: xtls-rprx-vision
|
||||
tls: true
|
||||
servername: node23.example.net
|
||||
reality-opts:
|
||||
public-key: 6lXdZ_RdGsI6N-DgEqXFiBI4S9NSNF1ncYxUc1sTous
|
||||
short-id: ""
|
||||
client-fingerprint: firefox
|
||||
- name: 🇸🇴 ОБХОД № 5 🐾
|
||||
type: hysteria2
|
||||
server: 198.51.100.20
|
||||
port: 443
|
||||
udp: true
|
||||
password: bd83cb66-b2d8-48e3-74f7-de34489f9b52
|
||||
sni: node9.example.net
|
||||
client-fingerprint: firefox
|
||||
alpn:
|
||||
- h3
|
||||
- name: 🇸🇴 ОБХОД № 6 🐾
|
||||
type: vless
|
||||
server: node1.example.net
|
||||
port: 443
|
||||
network: ws
|
||||
udp: true
|
||||
uuid: bd83cb66-b2d8-48e3-74f7-de34489f9b52
|
||||
packet-encoding: xudp
|
||||
tls: true
|
||||
servername: node1.example.net
|
||||
alpn:
|
||||
- http/1.1
|
||||
ws-opts:
|
||||
path: /api/v1/pipe/9179fab1af
|
||||
headers:
|
||||
Host: node1.example.net
|
||||
client-fingerprint: firefox
|
||||
- name: 🇸🇴 ОБХОД № 7
|
||||
type: vless
|
||||
server: 198.51.100.11
|
||||
port: 443
|
||||
network: grpc
|
||||
udp: true
|
||||
uuid: bd83cb66-b2d8-48e3-74f7-de34489f9b52
|
||||
packet-encoding: xudp
|
||||
tls: true
|
||||
servername: node23.example.net
|
||||
reality-opts:
|
||||
public-key: 9vjOrA0zirEzJCMLa0oWHrEtggRoESHiAmVf8rbgfuI
|
||||
short-id: ab952529e442f515
|
||||
grpc-opts:
|
||||
grpc-service-name: ""
|
||||
client-fingerprint: firefox
|
||||
- name: 🇸🇴 ОБХОД № 8
|
||||
type: vless
|
||||
server: node2.example.net
|
||||
port: 443
|
||||
network: ws
|
||||
udp: true
|
||||
uuid: bd83cb66-b2d8-48e3-74f7-de34489f9b52
|
||||
packet-encoding: xudp
|
||||
tls: true
|
||||
servername: node2.example.net
|
||||
alpn:
|
||||
- http/1.1
|
||||
ws-opts:
|
||||
path: /api/v1/stream/c429f9ceeb
|
||||
headers:
|
||||
Host: node2.example.net
|
||||
client-fingerprint: firefox
|
||||
- name: 🇸🇴 ОБХОД № 9 🐾
|
||||
type: vless
|
||||
server: 198.51.100.17
|
||||
port: 443
|
||||
network: tcp
|
||||
udp: true
|
||||
uuid: bd83cb66-b2d8-48e3-74f7-de34489f9b52
|
||||
packet-encoding: xudp
|
||||
flow: xtls-rprx-vision
|
||||
tls: true
|
||||
servername: node23.example.net
|
||||
reality-opts:
|
||||
public-key: URMM50Y7LxH7CKbIiFzqqM5dw-MvzQRNfFyq1c3oUhA
|
||||
short-id: ""
|
||||
client-fingerprint: firefox
|
||||
- name: 🇸🇴 ОБХОД № 10 🐾
|
||||
type: vless
|
||||
server: 198.51.100.12
|
||||
port: 443
|
||||
network: tcp
|
||||
udp: true
|
||||
uuid: bd83cb66-b2d8-48e3-74f7-de34489f9b52
|
||||
packet-encoding: xudp
|
||||
flow: xtls-rprx-vision
|
||||
tls: true
|
||||
servername: node23.example.net
|
||||
reality-opts:
|
||||
public-key: p4dHJ9NYfO7sQXczSZnxeg5gNj2avzlSd3maVYZ1Mys
|
||||
short-id: ""
|
||||
client-fingerprint: firefox
|
||||
- name: 🇸🇴 ОБХОД № 11 🐾
|
||||
type: vless
|
||||
server: 198.51.100.16
|
||||
port: 443
|
||||
network: tcp
|
||||
udp: true
|
||||
uuid: bd83cb66-b2d8-48e3-74f7-de34489f9b52
|
||||
packet-encoding: xudp
|
||||
flow: xtls-rprx-vision
|
||||
tls: true
|
||||
servername: node23.example.net
|
||||
reality-opts:
|
||||
public-key: UjzA7rkourlpTPHv5uXnG--gmo0Q4mUTPZBJ_2mr6Vs
|
||||
short-id: ""
|
||||
client-fingerprint: firefox
|
||||
- name: 🇸🇴 ОБХОД № 12 🐾
|
||||
type: vless
|
||||
server: 198.51.100.14
|
||||
port: 443
|
||||
network: grpc
|
||||
udp: true
|
||||
uuid: bd83cb66-b2d8-48e3-74f7-de34489f9b52
|
||||
packet-encoding: xudp
|
||||
tls: true
|
||||
servername: node20.example.net
|
||||
reality-opts:
|
||||
public-key: j-r0HS4k5-sln_Qn3Ef3jss7o_58JD8jYUlvqu2vNEY
|
||||
short-id: ""
|
||||
grpc-opts:
|
||||
grpc-service-name: ""
|
||||
client-fingerprint: firefox
|
||||
- name: 🇸🇴 ОБХОД № 13 🐾
|
||||
type: vless
|
||||
server: 198.51.100.18
|
||||
port: 443
|
||||
network: tcp
|
||||
udp: true
|
||||
uuid: bd83cb66-b2d8-48e3-74f7-de34489f9b52
|
||||
packet-encoding: xudp
|
||||
flow: xtls-rprx-vision
|
||||
tls: true
|
||||
servername: node23.example.net
|
||||
reality-opts:
|
||||
public-key: UjzA7rkourlpTPHv5uXnG--gmo0Q4mUTPZBJ_2mr6Vs
|
||||
short-id: ""
|
||||
client-fingerprint: firefox
|
||||
- name: 🇸🇴 ОБХОД № 14 🐾
|
||||
type: vless
|
||||
server: 198.51.100.10
|
||||
port: 8444
|
||||
network: tcp
|
||||
udp: true
|
||||
uuid: bd83cb66-b2d8-48e3-74f7-de34489f9b52
|
||||
packet-encoding: xudp
|
||||
flow: xtls-rprx-vision
|
||||
tls: true
|
||||
servername: node19.example.net
|
||||
reality-opts:
|
||||
public-key: x6lp08N37IOmUqVIrV5Nx-JKN8S3lmYozIdWIK1jbWg
|
||||
short-id: ""
|
||||
client-fingerprint: firefox
|
||||
- name: 🇸🇴 ОБХОД № 15 🐾
|
||||
type: vless
|
||||
server: node4.example.net
|
||||
port: 443
|
||||
network: tcp
|
||||
udp: true
|
||||
uuid: bd83cb66-b2d8-48e3-74f7-de34489f9b52
|
||||
packet-encoding: xudp
|
||||
flow: xtls-rprx-vision
|
||||
tls: true
|
||||
servername: node23.example.net
|
||||
reality-opts:
|
||||
public-key: eMwQlTXdSBLfCGLg-AOHH4BplelaPol8NixD5ycM4UQ
|
||||
short-id: ""
|
||||
client-fingerprint: firefox
|
||||
proxy-groups:
|
||||
- name: → Remnawave
|
||||
type: select
|
||||
proxies:
|
||||
- АВТО ВЫБОР ( ЛУЧШИЙ )
|
||||
- 🇺🇸 США
|
||||
- 🇱🇹 Литва
|
||||
- 🇦🇪 Дубай
|
||||
- 🇷🇺 Россия
|
||||
- 🇨🇦 Канада
|
||||
- " 🇸🇪 Швеция"
|
||||
- 🇭🇰 Гонконг
|
||||
- 🇵🇱 Польша
|
||||
- 🇵🇱 Польша 2
|
||||
- 🇪🇪 Эстония
|
||||
- 🇫🇷 Франция
|
||||
- 🇸🇬 Сингапур
|
||||
- 🇩🇪 Германия
|
||||
- 🇧🇷 Бразилия
|
||||
- 🇰🇿 Казахстан
|
||||
- 🇫🇮 Финляндия
|
||||
- 🇳🇱 Нидерланды
|
||||
- 🇸🇴 АВТО ОБХОД ( БС ) 🐾
|
||||
- 🇸🇴 ОБХОД № 1 🐾
|
||||
- 🇸🇴 ОБХОД № 2 🐾
|
||||
- 🇸🇴 ОБХОД № 3 🐾
|
||||
- 🇸🇴 ОБХОД № 4 🐾
|
||||
- 🇸🇴 ОБХОД № 5 🐾
|
||||
- 🇸🇴 ОБХОД № 6 🐾
|
||||
- 🇸🇴 ОБХОД № 7
|
||||
- 🇸🇴 ОБХОД № 8
|
||||
- 🇸🇴 ОБХОД № 9 🐾
|
||||
- 🇸🇴 ОБХОД № 10 🐾
|
||||
- 🇸🇴 ОБХОД № 11 🐾
|
||||
- 🇸🇴 ОБХОД № 12 🐾
|
||||
- 🇸🇴 ОБХОД № 13 🐾
|
||||
- 🇸🇴 ОБХОД № 14 🐾
|
||||
- 🇸🇴 ОБХОД № 15 🐾
|
||||
rules:
|
||||
- MATCH,→ Remnawave
|
||||
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,457 @@
|
||||
package subfmt
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/omar/prizma/internal/model"
|
||||
)
|
||||
|
||||
// Everything in this file is a parser-side helper. The names are deliberately
|
||||
// prefixed with b64/parse so the renderer half of the package (written
|
||||
// separately) can define its own helpers without colliding.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// base64
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// b64IsAlphabetByte reports whether c may appear inside a base64 blob of any
|
||||
// flavour: standard, URL-safe, padded or not.
|
||||
func b64IsAlphabetByte(c byte) bool {
|
||||
switch {
|
||||
case c >= 'A' && c <= 'Z', c >= 'a' && c <= 'z', c >= '0' && c <= '9':
|
||||
return true
|
||||
case c == '+', c == '/', c == '=', c == '-', c == '_':
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func b64IsSpaceByte(c byte) bool {
|
||||
switch c {
|
||||
case ' ', '\t', '\r', '\n', '\v', '\f':
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// b64IsLikely reports whether b is plausibly a base64 document. Real panels
|
||||
// wrap the blob at 64/76 columns, drop the padding and mix in the URL-safe
|
||||
// alphabet, so the only reliable signal is "nothing but the alphabet and
|
||||
// whitespace". The length floor keeps a stray one-word body from matching, and
|
||||
// a payload length of 4n+1 can never be valid base64.
|
||||
func b64IsLikely(b []byte) bool {
|
||||
n := 0
|
||||
for _, c := range b {
|
||||
if b64IsSpaceByte(c) {
|
||||
continue
|
||||
}
|
||||
if !b64IsAlphabetByte(c) {
|
||||
return false
|
||||
}
|
||||
n++
|
||||
}
|
||||
return n >= 16 && n%4 != 1
|
||||
}
|
||||
|
||||
// b64Decode decodes s tolerating the URL-safe alphabet, absent padding,
|
||||
// embedded newlines and even a blob that mixes both alphabets.
|
||||
func b64Decode(s string) ([]byte, bool) {
|
||||
var sb strings.Builder
|
||||
sb.Grow(len(s))
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if b64IsSpaceByte(c) || c == '=' {
|
||||
continue
|
||||
}
|
||||
switch c {
|
||||
case '-':
|
||||
c = '+'
|
||||
case '_':
|
||||
c = '/'
|
||||
}
|
||||
if !b64IsAlphabetByte(c) {
|
||||
return nil, false
|
||||
}
|
||||
sb.WriteByte(c)
|
||||
}
|
||||
t := sb.String()
|
||||
if t == "" || len(t)%4 == 1 {
|
||||
return nil, false
|
||||
}
|
||||
if pad := len(t) % 4; pad != 0 {
|
||||
t += strings.Repeat("=", 4-pad)
|
||||
}
|
||||
dec, err := base64.StdEncoding.DecodeString(t)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return dec, true
|
||||
}
|
||||
|
||||
// b64DecodeString is b64Decode for callers that want a string back and are
|
||||
// happy to fall back to the input when it is not base64 at all (ssr packs
|
||||
// several independently-encoded fields into one URI).
|
||||
func b64DecodeString(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
if dec, ok := b64Decode(s); ok {
|
||||
return string(dec)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// text
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
var utf8BOM = []byte{0xEF, 0xBB, 0xBF}
|
||||
|
||||
func parseTrimBOM(b []byte) []byte { return bytes.TrimPrefix(b, utf8BOM) }
|
||||
|
||||
// parseSplitLines splits on any line ending and drops blank lines.
|
||||
func parseSplitLines(b []byte) []string {
|
||||
raw := strings.FieldsFunc(string(b), func(r rune) bool {
|
||||
return r == '\n' || r == '\r'
|
||||
})
|
||||
out := make([]string, 0, len(raw))
|
||||
for _, ln := range raw {
|
||||
if ln = strings.TrimSpace(ln); ln != "" {
|
||||
out = append(out, ln)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// parsePercentDecode undoes %XX escaping, leaving '+' alone — a node name is a
|
||||
// URI fragment, not a query value, so '+' there is a literal plus.
|
||||
func parsePercentDecode(s string) string {
|
||||
if !strings.Contains(s, "%") {
|
||||
return s
|
||||
}
|
||||
if v, err := url.PathUnescape(s); err == nil {
|
||||
return v
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// parseCleanName makes a name safe to carry through YAML/JSON: valid UTF-8, no
|
||||
// control characters. Leading/trailing spaces are preserved on purpose —
|
||||
// upstreams really do ship names like " 🇸🇪 Швеция" and every format they
|
||||
// publish agrees on that space, so trimming would break cross-format identity.
|
||||
func parseCleanName(s string) string {
|
||||
s = strings.ToValidUTF8(s, "")
|
||||
return strings.Map(func(r rune) rune {
|
||||
if r == '\n' || r == '\r' || r == '\t' {
|
||||
return ' '
|
||||
}
|
||||
if r < 0x20 || r == 0x7f {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, s)
|
||||
}
|
||||
|
||||
// parseSplitHostPort splits "host:port", "[v6]:port", "host" and "[v6]".
|
||||
// The returned host never carries brackets.
|
||||
func parseSplitHostPort(s string) (string, int) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return "", 0
|
||||
}
|
||||
if strings.HasPrefix(s, "[") {
|
||||
if end := strings.Index(s, "]"); end >= 0 {
|
||||
host := s[1:end]
|
||||
if rest := s[end+1:]; strings.HasPrefix(rest, ":") {
|
||||
return host, parseAtoi(rest[1:])
|
||||
}
|
||||
return host, 0
|
||||
}
|
||||
}
|
||||
if c := strings.LastIndex(s, ":"); c >= 0 {
|
||||
if p, err := strconv.Atoi(strings.TrimSpace(s[c+1:])); err == nil {
|
||||
return strings.TrimSpace(s[:c]), p
|
||||
}
|
||||
}
|
||||
return s, 0
|
||||
}
|
||||
|
||||
func parseAtoi(s string) int {
|
||||
n, err := strconv.Atoi(strings.TrimSpace(s))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// parseLeadingInt reads the number at the front of s, so bandwidth hints like
|
||||
// "100 mbps" or "50m" survive.
|
||||
func parseLeadingInt(s string) int {
|
||||
s = strings.TrimSpace(s)
|
||||
i := 0
|
||||
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
|
||||
i++
|
||||
}
|
||||
if i == 0 {
|
||||
return 0
|
||||
}
|
||||
return parseAtoi(s[:i])
|
||||
}
|
||||
|
||||
func parseBoolString(s string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// parseCommaList splits an alpn-style list and drops empties.
|
||||
func parseCommaList(s string) []string {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(s, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// dynamic document access (YAML and JSON both land in map[string]any)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// parseAnyMap normalizes the map shapes a YAML or JSON decoder can produce.
|
||||
func parseAnyMap(v any) map[string]any {
|
||||
switch m := v.(type) {
|
||||
case map[string]any:
|
||||
return m
|
||||
case map[any]any: // yaml.v2 shape; harmless to support
|
||||
out := make(map[string]any, len(m))
|
||||
for k, val := range m {
|
||||
out[parseAnyString(k)] = val
|
||||
}
|
||||
return out
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseAnySlice(v any) []any {
|
||||
s, _ := v.([]any)
|
||||
return s
|
||||
}
|
||||
|
||||
func parseAnyString(v any) string {
|
||||
switch x := v.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
return x
|
||||
case bool:
|
||||
return strconv.FormatBool(x)
|
||||
case int:
|
||||
return strconv.Itoa(x)
|
||||
case int64:
|
||||
return strconv.FormatInt(x, 10)
|
||||
case float64:
|
||||
if x == float64(int64(x)) {
|
||||
return strconv.FormatInt(int64(x), 10)
|
||||
}
|
||||
return strconv.FormatFloat(x, 'f', -1, 64)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseAnyInt(v any) int {
|
||||
switch x := v.(type) {
|
||||
case int:
|
||||
return x
|
||||
case int64:
|
||||
return int(x)
|
||||
case float64:
|
||||
return int(x)
|
||||
case string:
|
||||
return parseLeadingInt(x)
|
||||
case bool:
|
||||
if x {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func parseAnyBool(v any) bool {
|
||||
switch x := v.(type) {
|
||||
case bool:
|
||||
return x
|
||||
case string:
|
||||
return parseBoolString(x)
|
||||
case int:
|
||||
return x != 0
|
||||
case float64:
|
||||
return x != 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// parseAnyStringList accepts both a real list and a comma-joined scalar.
|
||||
func parseAnyStringList(v any) []string {
|
||||
switch x := v.(type) {
|
||||
case []any:
|
||||
out := make([]string, 0, len(x))
|
||||
for _, item := range x {
|
||||
if s := strings.TrimSpace(parseAnyString(item)); s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
case []string:
|
||||
return append([]string(nil), x...)
|
||||
case string:
|
||||
return parseCommaList(x)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseDig walks nested maps: parseDig(m, "streamSettings", "tlsSettings").
|
||||
func parseDig(m map[string]any, path ...string) map[string]any {
|
||||
cur := m
|
||||
for _, k := range path {
|
||||
if cur == nil {
|
||||
return nil
|
||||
}
|
||||
cur = parseAnyMap(cur[k])
|
||||
}
|
||||
return cur
|
||||
}
|
||||
|
||||
// parseGet returns the first present key, so callers can spell out the
|
||||
// synonyms different panels use for the same field.
|
||||
func parseGet(m map[string]any, keys ...string) any {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
for _, k := range keys {
|
||||
if v, ok := m[k]; ok && v != nil {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseGetString(m map[string]any, keys ...string) string {
|
||||
return parseAnyString(parseGet(m, keys...))
|
||||
}
|
||||
|
||||
func parseGetInt(m map[string]any, keys ...string) int {
|
||||
return parseAnyInt(parseGet(m, keys...))
|
||||
}
|
||||
|
||||
func parseGetBool(m map[string]any, keys ...string) bool {
|
||||
return parseAnyBool(parseGet(m, keys...))
|
||||
}
|
||||
|
||||
// parseSetExtra stores a leftover that has no normalized home on Node.
|
||||
func parseSetExtra(n *model.Node, key string, val any) {
|
||||
if key == "" || val == nil {
|
||||
return
|
||||
}
|
||||
if s, ok := val.(string); ok && s == "" {
|
||||
return
|
||||
}
|
||||
if n.Extra == nil {
|
||||
n.Extra = make(map[string]any, 4)
|
||||
}
|
||||
n.Extra[key] = val
|
||||
}
|
||||
|
||||
// parseFallbackName is used when a format carries no label for a node; a Node
|
||||
// must never reach a renderer nameless.
|
||||
func parseFallbackName(n model.Node) string {
|
||||
switch {
|
||||
case n.Server != "" && n.Port > 0:
|
||||
return n.Server + ":" + strconv.Itoa(n.Port)
|
||||
case n.Server != "":
|
||||
return n.Server
|
||||
case n.Protocol != "":
|
||||
return n.Protocol
|
||||
}
|
||||
return "node"
|
||||
}
|
||||
|
||||
// parseNormNetwork maps the transport aliases every implementation spells
|
||||
// differently onto the set documented on model.Node.
|
||||
func parseNormNetwork(s string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "", "tcp", "raw", "none":
|
||||
return "tcp"
|
||||
case "ws", "websocket":
|
||||
return "ws"
|
||||
case "grpc", "gun":
|
||||
return "grpc"
|
||||
case "h2", "http/2":
|
||||
return "h2"
|
||||
case "http":
|
||||
return "http"
|
||||
case "httpupgrade":
|
||||
return "httpupgrade"
|
||||
case "xhttp", "splithttp":
|
||||
return "xhttp"
|
||||
case "quic":
|
||||
return "quic"
|
||||
case "kcp", "mkcp":
|
||||
return "kcp"
|
||||
default:
|
||||
return strings.ToLower(strings.TrimSpace(s))
|
||||
}
|
||||
}
|
||||
|
||||
// parseNormProtocol maps every spelling of a protocol seen in the wild onto the
|
||||
// model.Proto* constants. An unknown value is passed through lowercased so an
|
||||
// exotic node still survives instead of being dropped.
|
||||
func parseNormProtocol(s string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "vless":
|
||||
return model.ProtoVLESS
|
||||
case "vmess":
|
||||
return model.ProtoVMess
|
||||
case "trojan", "trojan-go":
|
||||
return model.ProtoTrojan
|
||||
case "ss", "shadowsocks":
|
||||
return model.ProtoShadowsock
|
||||
case "ssr", "shadowsocksr":
|
||||
return model.ProtoSSR
|
||||
case "hysteria", "hy":
|
||||
return model.ProtoHysteria
|
||||
case "hysteria2", "hy2":
|
||||
return model.ProtoHysteria2
|
||||
case "tuic":
|
||||
return model.ProtoTUIC
|
||||
case "anytls":
|
||||
return model.ProtoAnyTLS
|
||||
case "wireguard", "wg":
|
||||
return model.ProtoWireGuard
|
||||
case "socks", "socks5", "socks4", "socks4a", "socks5h":
|
||||
return model.ProtoSOCKS
|
||||
case "http", "https":
|
||||
return model.ProtoHTTP
|
||||
case "ssh":
|
||||
return model.ProtoSSH
|
||||
default:
|
||||
return strings.ToLower(strings.TrimSpace(s))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package upstream
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/omar/prizma/internal/model"
|
||||
"github.com/omar/prizma/internal/subfmt"
|
||||
)
|
||||
|
||||
// staleGrace is how long an expired entry is still kept around. It is the
|
||||
// difference between "the upstream panel is down for an hour" and "every client
|
||||
// loses its subscription for an hour": an entry past its TTL is never served to
|
||||
// a healthy path, but it IS served when the upstream refuses to answer.
|
||||
const staleGrace = 24 * time.Hour
|
||||
|
||||
// cacheKey buckets by source AND by the User-Agent actually presented upstream.
|
||||
// The UA decides which format the upstream renders (that is the whole point of
|
||||
// forwarding it), so a Clash client and a v2rayN client must not share a body.
|
||||
func cacheKey(sourceID int64, upstreamUA string) string {
|
||||
return strconv.FormatInt(sourceID, 10) + "\x00" + upstreamUA
|
||||
}
|
||||
|
||||
type cacheEntry struct {
|
||||
sourceID int64
|
||||
|
||||
body []byte
|
||||
status int
|
||||
header http.Header
|
||||
contentType string
|
||||
fetchedAt time.Time
|
||||
expiresAt time.Time
|
||||
|
||||
// Parsed form, filled opportunistically so a group link that merges N
|
||||
// sources does not re-parse the same body on every request.
|
||||
parsed bool
|
||||
nodes []model.Node
|
||||
format subfmt.Format
|
||||
parseErr error
|
||||
}
|
||||
|
||||
func (e *cacheEntry) fresh(now time.Time) bool { return now.Before(e.expiresAt) }
|
||||
|
||||
func (e *cacheEntry) usable(now time.Time) bool {
|
||||
return now.Before(e.expiresAt.Add(staleGrace))
|
||||
}
|
||||
|
||||
// result materializes an independent Result: callers own the returned header
|
||||
// map and may delete from it without corrupting the cache.
|
||||
func (e *cacheEntry) result(fromCache bool) *Result {
|
||||
return &Result{
|
||||
Body: e.body,
|
||||
Status: e.status,
|
||||
Header: e.header.Clone(),
|
||||
ContentType: e.contentType,
|
||||
FetchedAt: e.fetchedAt,
|
||||
FromCache: fromCache,
|
||||
}
|
||||
}
|
||||
|
||||
// respCache is a bounded map of upstream responses. It is bounded because the
|
||||
// key contains a client-controlled User-Agent: without a cap, a hostile or
|
||||
// merely diverse client population would grow it without limit.
|
||||
type respCache struct {
|
||||
mu sync.Mutex
|
||||
m map[string]*cacheEntry
|
||||
max int
|
||||
}
|
||||
|
||||
func newCache(max int) *respCache {
|
||||
if max <= 0 {
|
||||
max = 512
|
||||
}
|
||||
return &respCache{m: make(map[string]*cacheEntry), max: max}
|
||||
}
|
||||
|
||||
func (c *respCache) get(key string) (*cacheEntry, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
e, ok := c.m[key]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if !e.usable(time.Now()) {
|
||||
delete(c.m, key)
|
||||
return nil, false
|
||||
}
|
||||
return e, true
|
||||
}
|
||||
|
||||
func (c *respCache) put(key string, e *cacheEntry) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.m[key] = e
|
||||
c.evictLocked()
|
||||
}
|
||||
|
||||
// renew pushes an entry's freshness window forward without refetching. Used as
|
||||
// a failure backoff so a down upstream is retried on a timer, not per request.
|
||||
func (c *respCache) renew(key string, e *cacheEntry, d time.Duration) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.m[key] != e {
|
||||
return
|
||||
}
|
||||
if next := time.Now().Add(d); next.After(e.expiresAt) {
|
||||
e.expiresAt = next
|
||||
}
|
||||
}
|
||||
|
||||
// evictLocked drops the oldest entries once the cap is exceeded. A linear scan
|
||||
// is fine: it runs only on overflow and max is in the hundreds.
|
||||
func (c *respCache) evictLocked() {
|
||||
for len(c.m) > c.max {
|
||||
var oldestKey string
|
||||
var oldest time.Time
|
||||
for k, e := range c.m {
|
||||
if oldestKey == "" || e.fetchedAt.Before(oldest) {
|
||||
oldestKey, oldest = k, e.fetchedAt
|
||||
}
|
||||
}
|
||||
delete(c.m, oldestKey)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *respCache) invalidate(sourceID int64) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
for k, e := range c.m {
|
||||
if e.sourceID == sourceID {
|
||||
delete(c.m, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// call and flightGroup are a minimal singleflight: a burst of clients hitting
|
||||
// the same expired link must produce ONE upstream request, not one per client.
|
||||
// Rolling our own avoids pulling golang.org/x/sync into the module.
|
||||
type call struct {
|
||||
done chan struct{}
|
||||
res *Result
|
||||
err error
|
||||
}
|
||||
|
||||
type flightGroup struct {
|
||||
mu sync.Mutex
|
||||
m map[string]*call
|
||||
}
|
||||
|
||||
func (g *flightGroup) do(ctx context.Context, key string, fn func() (*Result, error)) (*Result, error) {
|
||||
g.mu.Lock()
|
||||
if g.m == nil {
|
||||
g.m = make(map[string]*call)
|
||||
}
|
||||
if c, ok := g.m[key]; ok {
|
||||
g.mu.Unlock()
|
||||
select {
|
||||
case <-c.done:
|
||||
return c.res, c.err
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
c := &call{done: make(chan struct{})}
|
||||
g.m[key] = c
|
||||
g.mu.Unlock()
|
||||
|
||||
c.res, c.err = fn()
|
||||
|
||||
g.mu.Lock()
|
||||
delete(g.m, key)
|
||||
g.mu.Unlock()
|
||||
close(c.done)
|
||||
|
||||
return c.res, c.err
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
// Package upstream fetches a Source's subscription from the panel that owns it.
|
||||
//
|
||||
// This is where Prizma's whole trick lives. The upstream panel pins a
|
||||
// subscription to a single device through the x-hwid header; Prizma presents
|
||||
// the source's stored HWID on every fetch so the upstream always sees the same
|
||||
// "device", while Prizma's own public link stays open to any number of clients.
|
||||
// Everything else about the exchange is deliberately left alone: the client's
|
||||
// User-Agent decides which format the upstream renders, and the response body
|
||||
// and headers come back untouched.
|
||||
package upstream
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/omar/prizma/internal/config"
|
||||
"github.com/omar/prizma/internal/model"
|
||||
"github.com/omar/prizma/internal/store"
|
||||
"github.com/omar/prizma/internal/subfmt"
|
||||
)
|
||||
|
||||
// maxBody caps what we will read from an upstream. Subscriptions are kilobytes;
|
||||
// anything past this is either a misconfigured URL or an attack on our memory.
|
||||
const maxBody = 16 << 20
|
||||
|
||||
// defaultUA is used only when a source pins no UA and the client sent none.
|
||||
// Panels routinely serve nothing at all to an empty User-Agent.
|
||||
const defaultUA = "Prizma"
|
||||
|
||||
// failureBackoff is how long a stale body is treated as fresh after the
|
||||
// upstream refused to answer, so a dead panel is retried periodically instead
|
||||
// of once per client request.
|
||||
const failureBackoff = 30 * time.Second
|
||||
|
||||
// Result is what the upstream answered, ready to be proxied verbatim.
|
||||
type Result struct {
|
||||
Body []byte
|
||||
Status int
|
||||
Header http.Header // upstream headers, hop-by-hop already stripped
|
||||
ContentType string
|
||||
FetchedAt time.Time
|
||||
FromCache bool
|
||||
}
|
||||
|
||||
// Fetcher performs and caches upstream subscription requests.
|
||||
type Fetcher struct {
|
||||
cfg config.Config
|
||||
st *store.Store
|
||||
hc *http.Client
|
||||
cache *respCache
|
||||
fl flightGroup
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// New builds a Fetcher. st may be nil, which disables fetch bookkeeping; the
|
||||
// HTTP path stays fully functional (used by tests and by one-off previews).
|
||||
func New(cfg config.Config, st *store.Store) *Fetcher {
|
||||
timeout := cfg.UpstreamTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = 20 * time.Second
|
||||
}
|
||||
tr := &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 10 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).DialContext,
|
||||
ForceAttemptHTTP2: true,
|
||||
MaxIdleConns: 100,
|
||||
MaxIdleConnsPerHost: 8,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: time.Second,
|
||||
}
|
||||
return &Fetcher{
|
||||
cfg: cfg,
|
||||
st: st,
|
||||
hc: &http.Client{Timeout: timeout, Transport: tr},
|
||||
cache: newCache(512),
|
||||
log: slog.Default(),
|
||||
}
|
||||
}
|
||||
|
||||
// Invalidate drops every cached body for a source, across all UA buckets.
|
||||
// Called after the source's URL, headers or HWID change.
|
||||
func (f *Fetcher) Invalidate(sourceID int64) { f.cache.invalidate(sourceID) }
|
||||
|
||||
// Fetch performs (or serves from cache) the upstream request for src.
|
||||
// clientUA and clientHeaders come from the incoming request; the HWID and
|
||||
// device headers are REPLACED with the source's own.
|
||||
func (f *Fetcher) Fetch(ctx context.Context, src model.Source, clientUA string, clientHeaders http.Header) (*Result, error) {
|
||||
ua := ResolveUA(src, clientUA)
|
||||
key := cacheKey(src.ID, ua)
|
||||
now := time.Now()
|
||||
|
||||
if e, ok := f.cache.get(key); ok && e.fresh(now) {
|
||||
return e.result(true), nil
|
||||
}
|
||||
|
||||
res, err := f.fl.do(ctx, key, func() (*Result, error) {
|
||||
// Another goroutine may have refreshed this key while we queued.
|
||||
if e, ok := f.cache.get(key); ok && e.fresh(time.Now()) {
|
||||
return e.result(true), nil
|
||||
}
|
||||
return f.fetchAndStore(ctx, src, key, ua, clientHeaders)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The flight result is shared between waiters; hand each caller its own
|
||||
// header map so the API layer can strip headers in place.
|
||||
out := *res
|
||||
out.Header = res.Header.Clone()
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (f *Fetcher) fetchAndStore(ctx context.Context, src model.Source, key, ua string, clientHeaders http.Header) (*Result, error) {
|
||||
entry, err := f.do(ctx, src, ua, clientHeaders)
|
||||
if err != nil || entry.status >= 500 {
|
||||
// Upstream is unhappy. A stale body beats a broken subscription on
|
||||
// every device the link serves, so fall back to it when we have one.
|
||||
if stale, ok := f.cache.get(key); ok {
|
||||
f.log.Warn("upstream failed, serving stale cache",
|
||||
"source", src.Name, "source_id", src.ID,
|
||||
"age", time.Since(stale.fetchedAt).Truncate(time.Second), "err", err)
|
||||
f.record(src, false, stale.nodes, errText(err, entry))
|
||||
// Back off before hammering a dead panel again: without this every
|
||||
// client request pays the full upstream timeout.
|
||||
f.cache.renew(key, stale, failureBackoff)
|
||||
return stale.result(true), nil
|
||||
}
|
||||
if err != nil {
|
||||
f.record(src, false, nil, err.Error())
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
entry.sourceID = src.ID
|
||||
entry.expiresAt = entry.fetchedAt.Add(f.ttl(src))
|
||||
// Only successful bodies are worth remembering; caching a 4xx would pin an
|
||||
// error page for the whole TTL.
|
||||
if entry.status >= 200 && entry.status < 300 {
|
||||
nodes, format, perr := subfmt.Parse(entry.body, entry.contentType)
|
||||
entry.nodes, entry.format, entry.parseErr, entry.parsed = nodes, format, perr, true
|
||||
f.cache.put(key, entry)
|
||||
f.record(src, true, nodes, parseNote(perr))
|
||||
} else {
|
||||
f.record(src, false, nil, fmt.Sprintf("upstream status %d", entry.status))
|
||||
}
|
||||
return entry.result(false), nil
|
||||
}
|
||||
|
||||
// do performs the actual HTTP round trip and normalizes the response.
|
||||
func (f *Fetcher) do(ctx context.Context, src model.Source, ua string, clientHeaders http.Header) (*cacheEntry, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, src.URL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
applyUpstreamHeaders(req, src, ua, clientHeaders)
|
||||
|
||||
resp, err := f.hc.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch %s: %w", src.URL, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBody+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read %s: %w", src.URL, err)
|
||||
}
|
||||
if len(body) > maxBody {
|
||||
return nil, fmt.Errorf("upstream %s returned more than %d bytes", src.URL, maxBody)
|
||||
}
|
||||
|
||||
h := resp.Header.Clone()
|
||||
stripHopByHop(h)
|
||||
|
||||
return &cacheEntry{
|
||||
body: body,
|
||||
status: resp.StatusCode,
|
||||
header: h,
|
||||
contentType: resp.Header.Get("Content-Type"),
|
||||
fetchedAt: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Nodes fetches and parses, for groups and for the UI preview.
|
||||
func (f *Fetcher) Nodes(ctx context.Context, src model.Source, clientUA string) ([]model.Node, subfmt.Format, error) {
|
||||
res, err := f.Fetch(ctx, src, clientUA, nil)
|
||||
if err != nil {
|
||||
return nil, subfmt.FormatUnknown, err
|
||||
}
|
||||
if res.Status < 200 || res.Status >= 300 {
|
||||
return nil, subfmt.FormatUnknown, fmt.Errorf("upstream %s returned status %d", src.Name, res.Status)
|
||||
}
|
||||
|
||||
// Reuse the parse done at fetch time when the entry is still around: a
|
||||
// group of ten sources would otherwise re-parse ten bodies per request.
|
||||
key := cacheKey(src.ID, ResolveUA(src, clientUA))
|
||||
if e, ok := f.cache.get(key); ok && e.parsed {
|
||||
if e.parseErr != nil && len(e.nodes) == 0 {
|
||||
return nil, e.format, e.parseErr
|
||||
}
|
||||
return cloneNodes(e.nodes), e.format, nil
|
||||
}
|
||||
|
||||
nodes, format, perr := subfmt.Parse(res.Body, res.ContentType)
|
||||
if perr != nil && len(nodes) == 0 {
|
||||
return nil, format, perr
|
||||
}
|
||||
return nodes, format, nil
|
||||
}
|
||||
|
||||
// ResolveUA implements the source's User-Agent policy.
|
||||
//
|
||||
// An empty Source.UserAgent forwards the client's UA verbatim — that is what
|
||||
// makes end-to-end format negotiation work: a Clash client asks Prizma, Prizma
|
||||
// asks the upstream as a Clash client, YAML comes back. A set UserAgent with
|
||||
// ForwardClientUA=false hides the client entirely and pins the upstream format;
|
||||
// with ForwardClientUA=true it is only the fallback for clients that send none.
|
||||
func ResolveUA(src model.Source, clientUA string) string {
|
||||
clientUA = strings.TrimSpace(clientUA)
|
||||
pinned := strings.TrimSpace(src.UserAgent)
|
||||
|
||||
if pinned == "" {
|
||||
if clientUA != "" {
|
||||
return clientUA
|
||||
}
|
||||
return defaultUA
|
||||
}
|
||||
if src.ForwardClientUA && clientUA != "" {
|
||||
return clientUA
|
||||
}
|
||||
return pinned
|
||||
}
|
||||
|
||||
// applyUpstreamHeaders builds the upstream request headers from scratch. It is
|
||||
// an allow-list on purpose: the client's cookies, Authorization and everything
|
||||
// else it happens to send must never reach the upstream panel.
|
||||
func applyUpstreamHeaders(req *http.Request, src model.Source, ua string, clientHeaders http.Header) {
|
||||
req.Header.Set("User-Agent", ua)
|
||||
|
||||
// The pinned device identity. This is the product.
|
||||
if src.HWID != "" {
|
||||
req.Header.Set("x-hwid", src.HWID)
|
||||
}
|
||||
if src.DeviceOS != "" {
|
||||
req.Header.Set("x-device-os", src.DeviceOS)
|
||||
}
|
||||
if src.VerOS != "" {
|
||||
req.Header.Set("x-ver-os", src.VerOS)
|
||||
}
|
||||
if src.DeviceModel != "" {
|
||||
req.Header.Set("x-device-model", src.DeviceModel)
|
||||
}
|
||||
|
||||
if accept := clientHeaders.Get("Accept"); accept != "" {
|
||||
req.Header.Set("Accept", accept)
|
||||
}
|
||||
if lang := clientHeaders.Get("Accept-Language"); lang != "" {
|
||||
req.Header.Set("Accept-Language", lang)
|
||||
}
|
||||
|
||||
// Operator-supplied headers win over everything above: they exist to work
|
||||
// around panels with unusual requirements.
|
||||
for k, v := range src.ExtraHeaders {
|
||||
if k == "" {
|
||||
continue
|
||||
}
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
// hopByHop are the per-connection headers that must never be relayed.
|
||||
var hopByHop = []string{
|
||||
"Connection",
|
||||
"Keep-Alive",
|
||||
"Transfer-Encoding",
|
||||
"Upgrade",
|
||||
"Trailer",
|
||||
"TE",
|
||||
"Proxy-Authenticate",
|
||||
"Proxy-Authorization",
|
||||
"Proxy-Connection",
|
||||
}
|
||||
|
||||
func stripHopByHop(h http.Header) {
|
||||
// Connection names further headers as hop-by-hop; honour that first.
|
||||
for _, v := range h.Values("Connection") {
|
||||
for _, name := range strings.Split(v, ",") {
|
||||
if name = strings.TrimSpace(name); name != "" {
|
||||
h.Del(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, k := range hopByHop {
|
||||
h.Del(k)
|
||||
}
|
||||
for k := range h {
|
||||
if strings.HasPrefix(strings.ToLower(k), "proxy-") {
|
||||
h.Del(k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (f *Fetcher) ttl(src model.Source) time.Duration {
|
||||
if src.CacheTTL > 0 {
|
||||
return time.Duration(src.CacheTTL) * time.Second
|
||||
}
|
||||
if f.cfg.CacheTTL > 0 {
|
||||
return f.cfg.CacheTTL
|
||||
}
|
||||
return 5 * time.Minute
|
||||
}
|
||||
|
||||
func (f *Fetcher) record(src model.Source, ok bool, nodes []model.Node, errMsg string) {
|
||||
if f.st == nil || src.ID == 0 {
|
||||
return
|
||||
}
|
||||
if err := f.st.RecordSourceFetch(src.ID, ok, len(nodes), errMsg); err != nil {
|
||||
f.log.Warn("record source fetch", "source_id", src.ID, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func errText(err error, entry *cacheEntry) string {
|
||||
if err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
if entry != nil {
|
||||
return fmt.Sprintf("upstream status %d", entry.status)
|
||||
}
|
||||
return "upstream unavailable"
|
||||
}
|
||||
|
||||
// parseNote records a parse problem without marking the FETCH as failed: the
|
||||
// bytes arrived, they are proxied byte-for-byte regardless, and only group
|
||||
// links care whether they were understood.
|
||||
func parseNote(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
return "parse: " + err.Error()
|
||||
}
|
||||
|
||||
func cloneNodes(in []model.Node) []model.Node {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]model.Node, len(in))
|
||||
for i, n := range in {
|
||||
out[i] = n.Clone()
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
package upstream
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/omar/prizma/internal/config"
|
||||
"github.com/omar/prizma/internal/model"
|
||||
)
|
||||
|
||||
func testCfg() config.Config {
|
||||
return config.Config{CacheTTL: time.Minute, UpstreamTimeout: 5 * time.Second}
|
||||
}
|
||||
|
||||
// captureServer records the headers of the last upstream request.
|
||||
func captureServer(t *testing.T, body string, extra map[string]string) (*httptest.Server, *http.Header) {
|
||||
t.Helper()
|
||||
var got http.Header
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
got = r.Header.Clone()
|
||||
for k, v := range extra {
|
||||
w.Header().Set(k, v)
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv, &got
|
||||
}
|
||||
|
||||
func TestFetchPresentsSourceHWIDAndDropsClientSecrets(t *testing.T) {
|
||||
srv, got := captureServer(t, "vless://x@1.2.3.4:443#node", nil)
|
||||
|
||||
f := New(testCfg(), nil)
|
||||
src := model.Source{
|
||||
ID: 1, Name: "panel", URL: srv.URL,
|
||||
HWID: "0123456789abcdef0123456789abcdef",
|
||||
DeviceOS: "iOS",
|
||||
VerOS: "17.4",
|
||||
DeviceModel: "iPhone16,1",
|
||||
ExtraHeaders: map[string]string{"X-Panel-Key": "s3cret"},
|
||||
}
|
||||
|
||||
client := http.Header{}
|
||||
client.Set("Cookie", "session=leak-me")
|
||||
client.Set("Authorization", "Bearer client-token")
|
||||
client.Set("X-Hwid", "device-of-the-actual-client")
|
||||
client.Set("Accept", "*/*")
|
||||
client.Set("Accept-Language", "ru-RU")
|
||||
|
||||
res, err := f.Fetch(context.Background(), src, "Clash/2024", client)
|
||||
if err != nil {
|
||||
t.Fatalf("Fetch: %v", err)
|
||||
}
|
||||
if res.Status != 200 {
|
||||
t.Fatalf("status = %d", res.Status)
|
||||
}
|
||||
|
||||
if v := got.Get("x-hwid"); v != src.HWID {
|
||||
t.Errorf("upstream x-hwid = %q, want the SOURCE hwid %q", v, src.HWID)
|
||||
}
|
||||
if v := got.Get("User-Agent"); v != "Clash/2024" {
|
||||
t.Errorf("upstream UA = %q, want the client UA forwarded", v)
|
||||
}
|
||||
for _, h := range []string{"Cookie", "Authorization"} {
|
||||
if v := got.Get(h); v != "" {
|
||||
t.Errorf("client %s leaked upstream as %q", h, v)
|
||||
}
|
||||
}
|
||||
if v := got.Get("x-device-os"); v != "iOS" {
|
||||
t.Errorf("x-device-os = %q", v)
|
||||
}
|
||||
if v := got.Get("x-ver-os"); v != "17.4" {
|
||||
t.Errorf("x-ver-os = %q", v)
|
||||
}
|
||||
if v := got.Get("x-device-model"); v != "iPhone16,1" {
|
||||
t.Errorf("x-device-model = %q", v)
|
||||
}
|
||||
if v := got.Get("X-Panel-Key"); v != "s3cret" {
|
||||
t.Errorf("extra header = %q", v)
|
||||
}
|
||||
if v := got.Get("Accept"); v != "*/*" {
|
||||
t.Errorf("Accept = %q", v)
|
||||
}
|
||||
if v := got.Get("Accept-Language"); v != "ru-RU" {
|
||||
t.Errorf("Accept-Language = %q", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveUA(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
src model.Source
|
||||
clientUA string
|
||||
want string
|
||||
}{
|
||||
{"empty pin forwards the client", model.Source{}, "Happ/1.2", "Happ/1.2"},
|
||||
{"empty pin and no client UA falls back", model.Source{}, "", defaultUA},
|
||||
{"pin hides the client", model.Source{UserAgent: "clash-verge/1.0"}, "Happ/1.2", "clash-verge/1.0"},
|
||||
{"pin plus forward prefers the client", model.Source{UserAgent: "clash-verge/1.0", ForwardClientUA: true}, "Happ/1.2", "Happ/1.2"},
|
||||
{"pin plus forward falls back when the client is silent", model.Source{UserAgent: "clash-verge/1.0", ForwardClientUA: true}, "", "clash-verge/1.0"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := ResolveUA(tc.src, tc.clientUA); got != tc.want {
|
||||
t.Errorf("ResolveUA = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchCachesPerUserAgent(t *testing.T) {
|
||||
var hits int64
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt64(&hits, 1)
|
||||
_, _ = w.Write([]byte("ua=" + r.UserAgent()))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
f := New(testCfg(), nil)
|
||||
src := model.Source{ID: 7, URL: srv.URL, HWID: "a"}
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
if _, err := f.Fetch(context.Background(), src, "clash", nil); err != nil {
|
||||
t.Fatalf("Fetch: %v", err)
|
||||
}
|
||||
}
|
||||
if n := atomic.LoadInt64(&hits); n != 1 {
|
||||
t.Fatalf("same UA hit the upstream %d times, want 1", n)
|
||||
}
|
||||
|
||||
// A different UA must NOT be answered from the clash bucket: the upstream
|
||||
// renders a different format for it.
|
||||
res, err := f.Fetch(context.Background(), src, "v2rayNG", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Fetch: %v", err)
|
||||
}
|
||||
if string(res.Body) != "ua=v2rayNG" {
|
||||
t.Fatalf("body = %q, want the v2rayNG rendering", res.Body)
|
||||
}
|
||||
if n := atomic.LoadInt64(&hits); n != 2 {
|
||||
t.Fatalf("upstream hits = %d, want 2", n)
|
||||
}
|
||||
|
||||
f.Invalidate(src.ID)
|
||||
if _, err := f.Fetch(context.Background(), src, "clash", nil); err != nil {
|
||||
t.Fatalf("Fetch after Invalidate: %v", err)
|
||||
}
|
||||
if n := atomic.LoadInt64(&hits); n != 3 {
|
||||
t.Fatalf("Invalidate did not drop the cached body (hits = %d)", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchServesStaleWhenUpstreamDies(t *testing.T) {
|
||||
var down atomic.Bool
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if down.Load() {
|
||||
http.Error(w, "boom", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte("good body"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// Zero TTL means every request re-checks the upstream, which is exactly the
|
||||
// case where the stale fallback has to work.
|
||||
f := New(config.Config{CacheTTL: time.Nanosecond, UpstreamTimeout: 5 * time.Second}, nil)
|
||||
src := model.Source{ID: 3, URL: srv.URL, HWID: "a"}
|
||||
|
||||
if _, err := f.Fetch(context.Background(), src, "clash", nil); err != nil {
|
||||
t.Fatalf("warm-up Fetch: %v", err)
|
||||
}
|
||||
|
||||
down.Store(true)
|
||||
res, err := f.Fetch(context.Background(), src, "clash", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Fetch with a dead upstream: %v", err)
|
||||
}
|
||||
if !res.FromCache {
|
||||
t.Error("FromCache = false, want the stale copy to be flagged")
|
||||
}
|
||||
if string(res.Body) != "good body" {
|
||||
t.Errorf("body = %q, want the last good body", res.Body)
|
||||
}
|
||||
if res.Status != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200 from the cached copy", res.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchFailsWhenNothingIsCached(t *testing.T) {
|
||||
f := New(testCfg(), nil)
|
||||
// Port 0 on the loopback is guaranteed to refuse a connection.
|
||||
_, err := f.Fetch(context.Background(), model.Source{ID: 1, URL: "http://127.0.0.1:0/sub"}, "clash", nil)
|
||||
if err == nil {
|
||||
t.Fatal("Fetch to a dead address returned no error and had nothing cached")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResultHeadersHaveHopByHopStripped(t *testing.T) {
|
||||
srv, _ := captureServer(t, "body", map[string]string{
|
||||
"Profile-Title": "base64:UHJpem1h",
|
||||
"Subscription-Userinfo": "upload=0; download=0; total=100",
|
||||
"Profile-Update-Interval": "12",
|
||||
})
|
||||
f := New(testCfg(), nil)
|
||||
res, err := f.Fetch(context.Background(), model.Source{ID: 2, URL: srv.URL, HWID: "a"}, "clash", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Fetch: %v", err)
|
||||
}
|
||||
for _, h := range []string{"Connection", "Keep-Alive", "Transfer-Encoding", "Upgrade", "Proxy-Connection"} {
|
||||
if v := res.Header.Get(h); v != "" {
|
||||
t.Errorf("hop-by-hop header %s survived as %q", h, v)
|
||||
}
|
||||
}
|
||||
if v := res.Header.Get("Profile-Title"); v != "base64:UHJpem1h" {
|
||||
t.Errorf("profile-title = %q, want it proxied through", v)
|
||||
}
|
||||
if v := res.Header.Get("Subscription-Userinfo"); !strings.Contains(v, "total=100") {
|
||||
t.Errorf("subscription-userinfo = %q, want it proxied through", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodesParsesTheUpstreamBody(t *testing.T) {
|
||||
const body = "vless://11111111-1111-1111-1111-111111111111@a.example:443?type=tcp&security=tls#Alpha\n" +
|
||||
"trojan://pw@b.example:443#Beta\n"
|
||||
srv, _ := captureServer(t, body, nil)
|
||||
|
||||
f := New(testCfg(), nil)
|
||||
nodes, format, err := f.Nodes(context.Background(), model.Source{ID: 5, URL: srv.URL, HWID: "a"}, "v2rayNG")
|
||||
if err != nil {
|
||||
t.Fatalf("Nodes: %v", err)
|
||||
}
|
||||
if len(nodes) != 2 {
|
||||
t.Fatalf("parsed %d nodes, want 2", len(nodes))
|
||||
}
|
||||
if format == "" {
|
||||
t.Error("Nodes returned an empty format")
|
||||
}
|
||||
if nodes[0].Name != "Alpha" || nodes[1].Name != "Beta" {
|
||||
t.Errorf("names = %q/%q", nodes[0].Name, nodes[1].Name)
|
||||
}
|
||||
|
||||
// Mutating the returned nodes must not poison the cached parse.
|
||||
nodes[0].Name = "clobbered"
|
||||
again, _, err := f.Nodes(context.Background(), model.Source{ID: 5, URL: srv.URL, HWID: "a"}, "v2rayNG")
|
||||
if err != nil {
|
||||
t.Fatalf("Nodes (second call): %v", err)
|
||||
}
|
||||
if again[0].Name != "Alpha" {
|
||||
t.Errorf("cached node was mutated by a caller: %q", again[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchCollapsesConcurrentRequests(t *testing.T) {
|
||||
var hits int64
|
||||
release := make(chan struct{})
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt64(&hits, 1)
|
||||
<-release
|
||||
_, _ = w.Write([]byte("body"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
f := New(testCfg(), nil)
|
||||
src := model.Source{ID: 11, URL: srv.URL, HWID: "a"}
|
||||
|
||||
const n = 12
|
||||
errs := make(chan error, n)
|
||||
for i := 0; i < n; i++ {
|
||||
go func() {
|
||||
_, err := f.Fetch(context.Background(), src, "clash", nil)
|
||||
errs <- err
|
||||
}()
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
close(release)
|
||||
for i := 0; i < n; i++ {
|
||||
if err := <-errs; err != nil {
|
||||
t.Fatalf("concurrent Fetch: %v", err)
|
||||
}
|
||||
}
|
||||
if got := atomic.LoadInt64(&hits); got != 1 {
|
||||
t.Fatalf("%d concurrent clients caused %d upstream requests, want 1", n, got)
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
<!doctype html>
|
||||
<!--
|
||||
Placeholder. //go:embed all:dist refuses an empty directory, so a clean
|
||||
checkout would not compile without this file. The real admin panel is built
|
||||
from web/ and copied over the whole dist/ directory:
|
||||
|
||||
cd web && npm ci && npm run build && cd ..
|
||||
rm -rf internal/webui/dist && cp -R web/dist internal/webui/dist
|
||||
|
||||
ci/build-binaries.sh and the Dockerfile both do exactly that, so a released
|
||||
binary never contains this page.
|
||||
-->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<title>Prizma — panel not built</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #0a0b11;
|
||||
color: #c8d0e0;
|
||||
font: 14px/1.65 ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||
}
|
||||
main { max-width: 36rem; padding: 2rem; }
|
||||
h1 { font-size: 1rem; letter-spacing: 0.22em; color: #2fd6c9; margin: 0 0 1.2rem; }
|
||||
code { color: #e9ecf7; }
|
||||
pre {
|
||||
background: #12141d;
|
||||
border: 1px solid #1e2130;
|
||||
border-radius: 6px;
|
||||
padding: 0.9rem 1rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>PRIZMA</h1>
|
||||
<p>
|
||||
The API is running — this binary simply has no admin panel embedded in
|
||||
it. You are seeing the placeholder that keeps <code>go build</code> green
|
||||
on a fresh checkout.
|
||||
</p>
|
||||
<pre>cd web && npm ci && npm run build && cd ..
|
||||
rm -rf internal/webui/dist && cp -R web/dist internal/webui/dist
|
||||
go build ./cmd/prizma</pre>
|
||||
<p>
|
||||
Or take a release binary / the docker image, where the panel is already
|
||||
baked in. Subscription links under <code>/sub/<token></code> and the
|
||||
REST API under <code>/api</code> work either way.
|
||||
</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,86 @@
|
||||
// Package webui serves the compiled React admin panel out of the binary.
|
||||
//
|
||||
// The whole SPA is embedded so that a Prizma release is one file with no
|
||||
// assets to deploy alongside it. web/dist is copied to internal/webui/dist by
|
||||
// the build; a committed placeholder index.html keeps `go build` green before
|
||||
// the frontend has ever been built.
|
||||
package webui
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed all:dist
|
||||
var dist embed.FS
|
||||
|
||||
// Handler serves the built SPA with history fallback to index.html.
|
||||
func Handler() http.Handler {
|
||||
sub, err := fs.Sub(dist, "dist")
|
||||
if err != nil {
|
||||
// Only reachable if the embed directive above stops matching, which is
|
||||
// a build-time mistake, not a runtime condition.
|
||||
panic("webui: embedded dist is missing: " + err.Error())
|
||||
}
|
||||
files := http.FileServer(http.FS(sub))
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
name := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/")
|
||||
if name == "" {
|
||||
serveIndex(w, r, sub)
|
||||
return
|
||||
}
|
||||
|
||||
f, err := sub.Open(name)
|
||||
if err != nil {
|
||||
// Any unknown path is a client-side route: hand back index.html and
|
||||
// let the router in the browser deal with it. Deep links and page
|
||||
// reloads inside the panel depend on this.
|
||||
serveIndex(w, r, sub)
|
||||
return
|
||||
}
|
||||
info, statErr := f.Stat()
|
||||
_ = f.Close()
|
||||
if statErr != nil || info.IsDir() {
|
||||
serveIndex(w, r, sub)
|
||||
return
|
||||
}
|
||||
|
||||
// Vite emits content-hashed asset names, so they may be cached hard;
|
||||
// everything else must be revalidated or a deploy would never land.
|
||||
if strings.HasPrefix(name, "assets/") {
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
} else {
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
}
|
||||
files.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func serveIndex(w http.ResponseWriter, r *http.Request, sub fs.FS) {
|
||||
body, err := fs.ReadFile(sub, "index.html")
|
||||
if err != nil {
|
||||
http.Error(w, "admin panel is not built", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
http.ServeContent(w, r, "index.html", indexModTime(sub), strings.NewReader(string(body)))
|
||||
}
|
||||
|
||||
// indexModTime keeps ServeContent's Last-Modified stable across restarts by
|
||||
// using the embedded file's own (zero) time rather than time.Now().
|
||||
func indexModTime(sub fs.FS) (t time.Time) {
|
||||
if f, err := sub.Open("index.html"); err == nil {
|
||||
defer f.Close()
|
||||
if info, err := f.Stat(); err == nil {
|
||||
return info.ModTime()
|
||||
}
|
||||
}
|
||||
return t
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<title>Prizma</title>
|
||||
<link
|
||||
rel="icon"
|
||||
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%230a0b11'/%3E%3Cpath d='M16 6 L26 24 H6 Z' fill='none' stroke='%23e9ecf7' stroke-width='2' stroke-linejoin='round'/%3E%3Cpath d='M4 15 h9' stroke='%23e9ecf7' stroke-width='2'/%3E%3Cpath d='M19 17 l9 -4' stroke='%23ff5f56' stroke-width='2'/%3E%3Cpath d='M20 19 l8 0' stroke='%23ffd166' stroke-width='2'/%3E%3Cpath d='M19 21 l9 4' stroke='%232fd6c9' stroke-width='2'/%3E%3C/svg%3E"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1881
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "prizma-web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"typecheck": "tsc -b",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"qrcode-generator": "^1.4.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.5",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.0.11"
|
||||
}
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { api, token, UNAUTHORIZED } from './lib/api'
|
||||
import { navigate, useRoute } from './lib/router'
|
||||
import { PageHeader, Shell, type NavCounts } from './components/Layout'
|
||||
import { ToastProvider, useToast } from './components/Toast'
|
||||
import { Login } from './pages/Login'
|
||||
import { Dashboard } from './pages/Dashboard'
|
||||
import { Sources } from './pages/Sources'
|
||||
import { Groups } from './pages/Groups'
|
||||
import { Clients } from './pages/Clients'
|
||||
import { EmptyState } from './components/States'
|
||||
|
||||
function Panel() {
|
||||
const route = useRoute()
|
||||
const [authed, setAuthed] = useState(() => !!token.get())
|
||||
const [user, setUser] = useState('admin')
|
||||
const [counts, setCounts] = useState<NavCounts>({})
|
||||
const toast = useToast()
|
||||
|
||||
// Any 401 anywhere lands here, so a stale token never leaves a blank page.
|
||||
useEffect(() => {
|
||||
const onUnauthorized = () => {
|
||||
setAuthed((was) => {
|
||||
if (was) toast.error('Session expired. Sign in again.')
|
||||
return false
|
||||
})
|
||||
}
|
||||
window.addEventListener(UNAUTHORIZED, onUnauthorized)
|
||||
return () => window.removeEventListener(UNAUTHORIZED, onUnauthorized)
|
||||
}, [toast])
|
||||
|
||||
// Who is signed in changes only at sign-in; the rail counts change as you
|
||||
// work, so they are refreshed on every navigation.
|
||||
useEffect(() => {
|
||||
if (!authed) return
|
||||
api
|
||||
.me()
|
||||
.then((me) => setUser(me?.username || me?.user || 'admin'))
|
||||
.catch(() => undefined)
|
||||
}, [authed])
|
||||
|
||||
const refreshCounts = useCallback(() => {
|
||||
api
|
||||
.stats()
|
||||
.then((s) => setCounts({ sources: s.sources, groups: s.groups, clients: s.clients }))
|
||||
.catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (authed) refreshCounts()
|
||||
}, [authed, refreshCounts, route])
|
||||
|
||||
if (!authed) {
|
||||
return (
|
||||
<Login
|
||||
onAuthed={() => {
|
||||
setAuthed(true)
|
||||
navigate('/')
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
token.clear()
|
||||
setAuthed(false)
|
||||
navigate('/')
|
||||
}
|
||||
|
||||
return (
|
||||
<Shell route={route} user={user} counts={counts} onLogout={logout}>
|
||||
{route === '/' ? (
|
||||
<Dashboard />
|
||||
) : route.startsWith('/sources') ? (
|
||||
<Sources />
|
||||
) : route.startsWith('/groups') ? (
|
||||
<Groups />
|
||||
) : route.startsWith('/clients') ? (
|
||||
<Clients />
|
||||
) : (
|
||||
<>
|
||||
<PageHeader eyebrow="Not found" title={route} />
|
||||
<div className="page">
|
||||
<div className="card">
|
||||
<div className="card__body">
|
||||
<EmptyState
|
||||
title="No such page"
|
||||
body="That route does not exist in the panel."
|
||||
action={
|
||||
<a className="btn btn--primary" href="#/">
|
||||
Back to the dashboard
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Shell>
|
||||
)
|
||||
}
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<ToastProvider>
|
||||
<Panel />
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { useEffect, useRef, type ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { IconX } from './Icons'
|
||||
import { cx, useEscape } from '../lib/util'
|
||||
|
||||
interface DrawerProps {
|
||||
open: boolean
|
||||
eyebrow: string
|
||||
title: string
|
||||
onClose: () => void
|
||||
onSubmit?: () => void
|
||||
footer?: ReactNode
|
||||
wide?: boolean
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/** Right-side editing surface. Escape closes, Ctrl/Cmd+Enter saves, focus lands
|
||||
* on the first control so the whole form is reachable from the keyboard. */
|
||||
export function Drawer({
|
||||
open,
|
||||
eyebrow,
|
||||
title,
|
||||
onClose,
|
||||
onSubmit,
|
||||
footer,
|
||||
wide,
|
||||
children,
|
||||
}: DrawerProps) {
|
||||
const bodyRef = useRef<HTMLDivElement>(null)
|
||||
useEscape(open, onClose)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const prev = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
const first = bodyRef.current?.querySelector<HTMLElement>(
|
||||
'input:not([type=checkbox]), textarea, select, button',
|
||||
)
|
||||
first?.focus()
|
||||
return () => {
|
||||
document.body.style.overflow = prev
|
||||
}
|
||||
}, [open])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<div className="overlay" onClick={onClose} />
|
||||
<aside
|
||||
className={cx('drawer', wide && 'drawer--wide')}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
onKeyDown={(e) => {
|
||||
if (onSubmit && (e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
onSubmit()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<header className="drawer__head">
|
||||
<div className="drawer__title">
|
||||
<div className="drawer__eyebrow">{eyebrow}</div>
|
||||
<div className="drawer__name">{title}</div>
|
||||
</div>
|
||||
<button className="ibtn" onClick={onClose} aria-label="Close">
|
||||
<IconX />
|
||||
</button>
|
||||
</header>
|
||||
<div className="drawer__body" ref={bodyRef}>
|
||||
{children}
|
||||
</div>
|
||||
{footer && <footer className="drawer__foot">{footer}</footer>}
|
||||
</aside>
|
||||
</>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
interface ModalProps {
|
||||
open: boolean
|
||||
title: string
|
||||
onClose: () => void
|
||||
width?: number
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/** Informational dialog — one way out, no decision to make. */
|
||||
export function Modal({ open, title, onClose, width = 560, children }: ModalProps) {
|
||||
const ref = useRef<HTMLButtonElement>(null)
|
||||
useEscape(open, onClose)
|
||||
useEffect(() => {
|
||||
if (open) ref.current?.focus()
|
||||
}, [open])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<div className="overlay" onClick={onClose} />
|
||||
<div
|
||||
className="modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
style={{ width: `min(${width}px, calc(100vw - 32px))` }}
|
||||
>
|
||||
<div className="modal__body">
|
||||
<div className="modal__title">{title}</div>
|
||||
{children}
|
||||
</div>
|
||||
<div className="modal__foot">
|
||||
<button ref={ref} className="btn" onClick={onClose}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
interface ConfirmProps {
|
||||
open: boolean
|
||||
title: string
|
||||
body: ReactNode
|
||||
confirmLabel?: string
|
||||
destructive?: boolean
|
||||
busy?: boolean
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export function Confirm({
|
||||
open,
|
||||
title,
|
||||
body,
|
||||
confirmLabel = 'Confirm',
|
||||
destructive,
|
||||
busy,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmProps) {
|
||||
const ref = useRef<HTMLButtonElement>(null)
|
||||
useEscape(open, onCancel)
|
||||
useEffect(() => {
|
||||
if (open) ref.current?.focus()
|
||||
}, [open])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<div className="overlay" onClick={onCancel} />
|
||||
<div className="modal" role="alertdialog" aria-modal="true" aria-label={title}>
|
||||
<div className="modal__body">
|
||||
<div className="modal__title">{title}</div>
|
||||
<div className="modal__text">{body}</div>
|
||||
</div>
|
||||
<div className="modal__foot">
|
||||
<button className="btn" onClick={onCancel} disabled={busy}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
ref={ref}
|
||||
className={cx('btn', destructive ? 'btn--danger' : 'btn--primary')}
|
||||
onClick={onConfirm}
|
||||
disabled={busy}
|
||||
>
|
||||
{busy && <span className="spinner" />}
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { Filter } from '../lib/types'
|
||||
import { PROTOCOLS, protoHue } from '../lib/spectrum'
|
||||
import { cx, regexError } from '../lib/util'
|
||||
import { Field, PillSelect, Toggle } from './Form'
|
||||
|
||||
interface Props {
|
||||
value: Filter
|
||||
onChange: (f: Filter) => void
|
||||
/** Compact drops the protocol and limit controls, for the per-member case
|
||||
* where those belong to the group as a whole. */
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
/** Name and content regexes run in the order the model documents:
|
||||
* include name → exclude name → include content → exclude content →
|
||||
* protocols → limit. The labels say so, so nobody has to read the Go. */
|
||||
export function FilterEditor({ value, onChange, compact }: Props) {
|
||||
const set = <K extends keyof Filter>(k: K, v: Filter[K]) =>
|
||||
onChange({ ...value, [k]: v })
|
||||
|
||||
const rx = (v?: string) => regexError(v ?? '')
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s3)' }}>
|
||||
<div className="form-grid">
|
||||
<Field
|
||||
label="Keep names matching"
|
||||
error={rx(value.include_name)}
|
||||
hint={compact ? undefined : 'Blank keeps everything.'}
|
||||
>
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
className={cx('input input--mono', rx(value.include_name) && 'is-invalid')}
|
||||
value={value.include_name ?? ''}
|
||||
spellCheck={false}
|
||||
placeholder="🇩🇪|DE|Germany"
|
||||
onChange={(e) => set('include_name', e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Drop names matching" error={rx(value.exclude_name)}>
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
className={cx('input input--mono', rx(value.exclude_name) && 'is-invalid')}
|
||||
value={value.exclude_name ?? ''}
|
||||
spellCheck={false}
|
||||
placeholder="test|expire|trial"
|
||||
onChange={(e) => set('exclude_name', e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field
|
||||
label="Keep content matching"
|
||||
error={rx(value.include_content)}
|
||||
hint={compact ? undefined : 'Matched against the whole node, not just its name.'}
|
||||
>
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
className={cx('input input--mono', rx(value.include_content) && 'is-invalid')}
|
||||
value={value.include_content ?? ''}
|
||||
spellCheck={false}
|
||||
placeholder="reality"
|
||||
onChange={(e) => set('include_content', e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Drop content matching" error={rx(value.exclude_content)}>
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
className={cx('input input--mono', rx(value.exclude_content) && 'is-invalid')}
|
||||
value={value.exclude_content ?? ''}
|
||||
spellCheck={false}
|
||||
placeholder="ws\\?path"
|
||||
onChange={(e) => set('exclude_content', e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{!compact && (
|
||||
<>
|
||||
<Field label="Protocols" group hint="Nothing selected allows every protocol.">
|
||||
{() => (
|
||||
<PillSelect
|
||||
ariaLabel="Allowed protocols"
|
||||
options={PROTOCOLS}
|
||||
selected={value.protocols ?? []}
|
||||
hueOf={protoHue}
|
||||
onChange={(v) => set('protocols', v)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<div className="form-grid">
|
||||
<Field label="Limit" hint="Caps the surviving nodes. 0 is unlimited.">
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
type="number"
|
||||
min={0}
|
||||
className="input input--mono"
|
||||
value={value.limit ?? 0}
|
||||
onChange={(e) => set('limit', Number(e.target.value) || 0)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', paddingBottom: 6 }}>
|
||||
<Toggle
|
||||
checked={!!value.case_sensitive}
|
||||
onChange={(v) => set('case_sensitive', v)}
|
||||
title="Case sensitive"
|
||||
sub="Applies to every pattern above."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import { useId, useState, type ReactNode } from 'react'
|
||||
import { cx } from '../lib/util'
|
||||
import { IconX } from './Icons'
|
||||
|
||||
interface FieldProps {
|
||||
label: string
|
||||
hint?: ReactNode
|
||||
error?: string | null
|
||||
required?: boolean
|
||||
className?: string
|
||||
/** Set when the field holds several controls (a pill row, a button group)
|
||||
* rather than one — <label for> would then point at nothing. */
|
||||
group?: boolean
|
||||
children: (id: string) => ReactNode
|
||||
}
|
||||
|
||||
/** Label + control + hint/error, wired by a generated id so the label is real. */
|
||||
export function Field({
|
||||
label,
|
||||
hint,
|
||||
error,
|
||||
required,
|
||||
className,
|
||||
group,
|
||||
children,
|
||||
}: FieldProps) {
|
||||
const id = useId()
|
||||
const caption = (
|
||||
<>
|
||||
{label}
|
||||
{required && <span className="field__req">required</span>}
|
||||
</>
|
||||
)
|
||||
return (
|
||||
<div className={cx('field', className)}>
|
||||
{group ? (
|
||||
<div className="field__label" id={id}>
|
||||
{caption}
|
||||
</div>
|
||||
) : (
|
||||
<label className="field__label" htmlFor={id}>
|
||||
{caption}
|
||||
</label>
|
||||
)}
|
||||
{group ? (
|
||||
<div role="group" aria-labelledby={id}>
|
||||
{children(id)}
|
||||
</div>
|
||||
) : (
|
||||
children(id)
|
||||
)}
|
||||
{error ? (
|
||||
<span className="field__error">{error}</span>
|
||||
) : hint ? (
|
||||
<span className="field__hint">{hint}</span>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ToggleProps {
|
||||
checked: boolean
|
||||
onChange: (v: boolean) => void
|
||||
title: string
|
||||
sub?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function Toggle({ checked, onChange, title, sub, disabled }: ToggleProps) {
|
||||
return (
|
||||
<label className="toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
/>
|
||||
<span className="toggle__track" />
|
||||
<span className="toggle__text">
|
||||
<span className="toggle__title">{title}</span>
|
||||
{sub && <span className="toggle__sub">{sub}</span>}
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
interface SegmentedProps<T extends string> {
|
||||
value: T
|
||||
options: { value: T; label: string }[]
|
||||
onChange: (v: T) => void
|
||||
ariaLabel: string
|
||||
}
|
||||
|
||||
export function Segmented<T extends string>({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
ariaLabel,
|
||||
}: SegmentedProps<T>) {
|
||||
return (
|
||||
<div className="seg" role="group" aria-label={ariaLabel}>
|
||||
{options.map((o) => (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
className={cx('seg__btn', o.value === value && 'is-on')}
|
||||
aria-pressed={o.value === value}
|
||||
onClick={() => onChange(o.value)}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface PillsProps {
|
||||
options: readonly string[]
|
||||
selected: string[]
|
||||
onChange: (next: string[]) => void
|
||||
hueOf?: (value: string) => string
|
||||
ariaLabel: string
|
||||
}
|
||||
|
||||
export function PillSelect({
|
||||
options,
|
||||
selected,
|
||||
onChange,
|
||||
hueOf,
|
||||
ariaLabel,
|
||||
}: PillsProps) {
|
||||
const toggle = (v: string) =>
|
||||
onChange(selected.includes(v) ? selected.filter((x) => x !== v) : [...selected, v])
|
||||
return (
|
||||
<div className="pills" role="group" aria-label={ariaLabel}>
|
||||
{options.map((o) => {
|
||||
const on = selected.includes(o)
|
||||
return (
|
||||
<button
|
||||
key={o}
|
||||
type="button"
|
||||
className={cx('pill', on && 'is-on')}
|
||||
aria-pressed={on}
|
||||
style={hueOf ? ({ ['--hue' as string]: hueOf(o) } as never) : undefined}
|
||||
onClick={() => toggle(o)}
|
||||
>
|
||||
{o}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface HeadersEditorProps {
|
||||
value: Record<string, string>
|
||||
onChange: (v: Record<string, string>) => void
|
||||
}
|
||||
|
||||
/** Extra upstream headers. The editor owns an ordered pair list rather than the
|
||||
* map itself, so a half-typed row with an empty name survives keystrokes; the
|
||||
* map handed back upstream always has the blank names dropped. */
|
||||
export function HeadersEditor({ value, onChange }: HeadersEditorProps) {
|
||||
const [rows, setRows] = useState<[string, string][]>(() => Object.entries(value))
|
||||
|
||||
const apply = (next: [string, string][]) => {
|
||||
setRows(next)
|
||||
const out: Record<string, string> = {}
|
||||
for (const [k, v] of next) if (k.trim()) out[k.trim()] = v
|
||||
onChange(out)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="kv">
|
||||
{rows.map(([k, v], i) => (
|
||||
<div className="kv__row" key={i}>
|
||||
<input
|
||||
className="input input--mono"
|
||||
value={k}
|
||||
aria-label={`Header ${i + 1} name`}
|
||||
placeholder="x-example"
|
||||
onChange={(e) => {
|
||||
const next = [...rows]
|
||||
next[i] = [e.target.value, v]
|
||||
apply(next)
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
className="input input--mono"
|
||||
value={v}
|
||||
aria-label={`Header ${i + 1} value`}
|
||||
placeholder="value"
|
||||
onChange={(e) => {
|
||||
const next = [...rows]
|
||||
next[i] = [k, e.target.value]
|
||||
apply(next)
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="ibtn is-danger"
|
||||
aria-label={`Remove header ${k || `row ${i + 1}`}`}
|
||||
onClick={() => apply(rows.filter((_, j) => j !== i))}
|
||||
>
|
||||
<IconX size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm"
|
||||
onClick={() => apply([...rows, ['', '']])}
|
||||
>
|
||||
Add header
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import type { SVGProps } from 'react'
|
||||
|
||||
type P = SVGProps<SVGSVGElement> & { size?: number }
|
||||
|
||||
function Svg({ size = 16, children, ...rest }: P) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.7}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export const IconGauge = (p: P) => (
|
||||
<Svg {...p}>
|
||||
<path d="M12 20a8 8 0 1 0-8-8" />
|
||||
<path d="M4 20h16" />
|
||||
<path d="m12 14 5-5" />
|
||||
</Svg>
|
||||
)
|
||||
|
||||
export const IconSource = (p: P) => (
|
||||
<Svg {...p}>
|
||||
<path d="M4 7c0-1.7 3.6-3 8-3s8 1.3 8 3-3.6 3-8 3-8-1.3-8-3Z" />
|
||||
<path d="M4 7v10c0 1.7 3.6 3 8 3s8-1.3 8-3V7" />
|
||||
<path d="M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3" />
|
||||
</Svg>
|
||||
)
|
||||
|
||||
export const IconGroup = (p: P) => (
|
||||
<Svg {...p}>
|
||||
<path d="M4 6h4" />
|
||||
<path d="M4 12h4" />
|
||||
<path d="M4 18h4" />
|
||||
<path d="M8 6c5 0 3 6 8 6" />
|
||||
<path d="M8 18c5 0 3-6 8-6" />
|
||||
<circle cx="18" cy="12" r="2" />
|
||||
</Svg>
|
||||
)
|
||||
|
||||
export const IconDevice = (p: P) => (
|
||||
<Svg {...p}>
|
||||
<rect x="7" y="3" width="10" height="18" rx="2.5" />
|
||||
<path d="M11 18h2" />
|
||||
</Svg>
|
||||
)
|
||||
|
||||
export const IconPlus = (p: P) => (
|
||||
<Svg {...p}>
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</Svg>
|
||||
)
|
||||
|
||||
export const IconCopy = (p: P) => (
|
||||
<Svg {...p}>
|
||||
<rect x="9" y="9" width="11" height="11" rx="2" />
|
||||
<path d="M15 9V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h3" />
|
||||
</Svg>
|
||||
)
|
||||
|
||||
export const IconCheck = (p: P) => (
|
||||
<Svg {...p}>
|
||||
<path d="m4 12.5 5 5L20 6.5" />
|
||||
</Svg>
|
||||
)
|
||||
|
||||
export const IconX = (p: P) => (
|
||||
<Svg {...p}>
|
||||
<path d="M6 6l12 12M18 6 6 18" />
|
||||
</Svg>
|
||||
)
|
||||
|
||||
export const IconQr = (p: P) => (
|
||||
<Svg {...p}>
|
||||
<rect x="3" y="3" width="7" height="7" rx="1.5" />
|
||||
<rect x="14" y="3" width="7" height="7" rx="1.5" />
|
||||
<rect x="3" y="14" width="7" height="7" rx="1.5" />
|
||||
<path d="M14 14h3v3h-3zM20 14v1M14 20h3M20 19v2" />
|
||||
</Svg>
|
||||
)
|
||||
|
||||
export const IconEdit = (p: P) => (
|
||||
<Svg {...p}>
|
||||
<path d="M4 20h4L19 9a2.5 2.5 0 0 0-3.5-3.5L4 16.5V20Z" />
|
||||
<path d="M14 7.5 16.5 10" />
|
||||
</Svg>
|
||||
)
|
||||
|
||||
export const IconTrash = (p: P) => (
|
||||
<Svg {...p}>
|
||||
<path d="M4 7h16M10 7V5h4v2M6 7l1 13h10l1-13" />
|
||||
</Svg>
|
||||
)
|
||||
|
||||
export const IconRefresh = (p: P) => (
|
||||
<Svg {...p}>
|
||||
<path d="M20 11a8 8 0 0 0-14-4.5L4 9" />
|
||||
<path d="M4 5v4h4" />
|
||||
<path d="M4 13a8 8 0 0 0 14 4.5L20 15" />
|
||||
<path d="M20 19v-4h-4" />
|
||||
</Svg>
|
||||
)
|
||||
|
||||
export const IconPlay = (p: P) => (
|
||||
<Svg {...p}>
|
||||
<path d="M8 5.5 18.5 12 8 18.5Z" />
|
||||
</Svg>
|
||||
)
|
||||
|
||||
export const IconSearch = (p: P) => (
|
||||
<Svg {...p}>
|
||||
<circle cx="11" cy="11" r="6.5" />
|
||||
<path d="m16 16 4 4" />
|
||||
</Svg>
|
||||
)
|
||||
|
||||
export const IconBan = (p: P) => (
|
||||
<Svg {...p}>
|
||||
<circle cx="12" cy="12" r="8.5" />
|
||||
<path d="m6 6 12 12" />
|
||||
</Svg>
|
||||
)
|
||||
|
||||
export const IconShield = (p: P) => (
|
||||
<Svg {...p}>
|
||||
<path d="M12 3.5 5 6v6c0 4.3 3 7.3 7 8.5 4-1.2 7-4.2 7-8.5V6l-7-2.5Z" />
|
||||
<path d="m9 12 2 2 4-4" />
|
||||
</Svg>
|
||||
)
|
||||
|
||||
export const IconShieldOff = (p: P) => (
|
||||
<Svg {...p}>
|
||||
<path d="M12 3.5 5 6v6c0 4.3 3 7.3 7 8.5 4-1.2 7-4.2 7-8.5V6l-7-2.5Z" />
|
||||
<path d="m10 10 4 4M14 10l-4 4" />
|
||||
</Svg>
|
||||
)
|
||||
|
||||
export const IconAlert = (p: P) => (
|
||||
<Svg {...p}>
|
||||
<path d="M12 4.5 2.8 20h18.4L12 4.5Z" />
|
||||
<path d="M12 10v4.5M12 17.4v.2" />
|
||||
</Svg>
|
||||
)
|
||||
|
||||
export const IconInfo = (p: P) => (
|
||||
<Svg {...p}>
|
||||
<circle cx="12" cy="12" r="8.5" />
|
||||
<path d="M12 11v5M12 8.1v.2" />
|
||||
</Svg>
|
||||
)
|
||||
|
||||
export const IconLogout = (p: P) => (
|
||||
<Svg {...p}>
|
||||
<path d="M14 4h4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-4" />
|
||||
<path d="M10 8 6 12l4 4M6 12h10" />
|
||||
</Svg>
|
||||
)
|
||||
|
||||
export const IconChevronUp = (p: P) => (
|
||||
<Svg {...p} strokeWidth={2}>
|
||||
<path d="m6 15 6-6 6 6" />
|
||||
</Svg>
|
||||
)
|
||||
|
||||
export const IconChevronDown = (p: P) => (
|
||||
<Svg {...p} strokeWidth={2}>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</Svg>
|
||||
)
|
||||
|
||||
export const IconChevronLeft = (p: P) => (
|
||||
<Svg {...p} strokeWidth={2}>
|
||||
<path d="m14 6-6 6 6 6" />
|
||||
</Svg>
|
||||
)
|
||||
|
||||
export const IconChevronRight = (p: P) => (
|
||||
<Svg {...p} strokeWidth={2}>
|
||||
<path d="m10 6 6 6-6 6" />
|
||||
</Svg>
|
||||
)
|
||||
|
||||
export const IconDice = (p: P) => (
|
||||
<Svg {...p}>
|
||||
<rect x="4" y="4" width="16" height="16" rx="3" />
|
||||
<g fill="currentColor" stroke="none">
|
||||
<circle cx="9" cy="9" r="1.35" />
|
||||
<circle cx="15" cy="9" r="1.35" />
|
||||
<circle cx="12" cy="12" r="1.35" />
|
||||
<circle cx="9" cy="15" r="1.35" />
|
||||
<circle cx="15" cy="15" r="1.35" />
|
||||
</g>
|
||||
</Svg>
|
||||
)
|
||||
|
||||
export const IconEmpty = (p: P) => (
|
||||
<Svg {...p} strokeWidth={1.2}>
|
||||
<path d="M4 8h16v11a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V8Z" />
|
||||
<path d="M4 8 6.5 4h11L20 8" />
|
||||
<path d="M9 12h6" />
|
||||
</Svg>
|
||||
)
|
||||
|
||||
/** The mark: a beam entering glass and leaving as a spectrum. */
|
||||
export function PrismMark({ size = 26 }: { size?: number }) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 32 32"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<path
|
||||
d="M16 5.5 27 25.5H5Z"
|
||||
stroke="var(--ink)"
|
||||
strokeWidth="1.6"
|
||||
strokeLinejoin="round"
|
||||
opacity="0.9"
|
||||
/>
|
||||
<path d="M2 15.5h11" stroke="var(--ink)" strokeWidth="1.6" strokeLinecap="round" />
|
||||
<path d="M19 16.5 30 11" stroke="var(--sp-0)" strokeWidth="1.6" strokeLinecap="round" />
|
||||
<path d="M19.6 18 30 15" stroke="var(--sp-2)" strokeWidth="1.6" strokeLinecap="round" />
|
||||
<path d="M20.2 19.5 30 19" stroke="var(--sp-3)" strokeWidth="1.6" strokeLinecap="round" />
|
||||
<path d="M20.8 21 30 23" stroke="var(--sp-4)" strokeWidth="1.6" strokeLinecap="round" />
|
||||
<path d="M21.4 22.5 30 27" stroke="var(--sp-6)" strokeWidth="1.6" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { href } from '../lib/router'
|
||||
import { cx } from '../lib/util'
|
||||
import {
|
||||
IconDevice,
|
||||
IconGauge,
|
||||
IconGroup,
|
||||
IconLogout,
|
||||
IconSource,
|
||||
PrismMark,
|
||||
} from './Icons'
|
||||
|
||||
export interface NavCounts {
|
||||
sources?: number
|
||||
groups?: number
|
||||
clients?: number
|
||||
}
|
||||
|
||||
const ITEMS = [
|
||||
{ path: '/', label: 'Dashboard', icon: IconGauge, key: undefined },
|
||||
{ path: '/sources', label: 'Sources', icon: IconSource, key: 'sources' },
|
||||
{ path: '/groups', label: 'Groups', icon: IconGroup, key: 'groups' },
|
||||
{ path: '/clients', label: 'Clients', icon: IconDevice, key: 'clients' },
|
||||
] as const
|
||||
|
||||
export function Shell({
|
||||
route,
|
||||
user,
|
||||
counts,
|
||||
onLogout,
|
||||
children,
|
||||
}: {
|
||||
route: string
|
||||
user: string
|
||||
counts: NavCounts
|
||||
onLogout: () => void
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="shell">
|
||||
<nav className="rail" aria-label="Sections">
|
||||
<div className="brand">
|
||||
<span className="brand__mark">
|
||||
<PrismMark />
|
||||
</span>
|
||||
<span className="brand__text">
|
||||
<span className="brand__name">Prizma</span>
|
||||
<span className="brand__tag">one link, every device</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="nav">
|
||||
{ITEMS.map((it) => {
|
||||
const active = it.path === '/' ? route === '/' : route.startsWith(it.path)
|
||||
const Icon = it.icon
|
||||
const count = it.key ? counts[it.key] : undefined
|
||||
return (
|
||||
<a
|
||||
key={it.path}
|
||||
className={cx('nav__item', active && 'is-active')}
|
||||
href={href(it.path)}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
>
|
||||
<span className="nav__icon">
|
||||
<Icon size={15} />
|
||||
</span>
|
||||
<span className="nav__label">{it.label}</span>
|
||||
{count !== undefined && <span className="nav__count">{count}</span>}
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="rail__foot">
|
||||
<div className="rail__user">
|
||||
<span className="rail__who" title={user}>
|
||||
{user}
|
||||
</span>
|
||||
<button className="ibtn" onClick={onLogout} aria-label="Sign out" title="Sign out">
|
||||
<IconLogout size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main className="main">{children}</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function PageHeader({
|
||||
eyebrow,
|
||||
title,
|
||||
actions,
|
||||
}: {
|
||||
eyebrow: string
|
||||
title: string
|
||||
actions?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<header className="topbar">
|
||||
<div className="topbar__title">
|
||||
<div className="topbar__eyebrow">{eyebrow}</div>
|
||||
<h1 className="topbar__h1">{title}</h1>
|
||||
</div>
|
||||
{actions && <div className="topbar__actions">{actions}</div>}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { Node } from '../lib/types'
|
||||
import { protoHue, sourceHue } from '../lib/spectrum'
|
||||
|
||||
/** The merged spectrum: one row per node, the left tick coloured by the source
|
||||
* it came from, so a group preview literally shows several beams combined. */
|
||||
export function NodeList({
|
||||
nodes,
|
||||
showSource,
|
||||
max = 400,
|
||||
}: {
|
||||
nodes: Node[]
|
||||
showSource?: boolean
|
||||
max?: number
|
||||
}) {
|
||||
const shown = nodes.slice(0, max)
|
||||
return (
|
||||
<div className="nodes">
|
||||
{shown.map((n, i) => {
|
||||
const hue = showSource && n.source_id ? sourceHue(n.source_id) : protoHue(n.protocol)
|
||||
return (
|
||||
<div className="node" key={`${i}-${n.name}-${n.server}`}>
|
||||
<span className="node__idx">{i + 1}</span>
|
||||
<span className="node__tick" style={{ ['--hue' as string]: hue } as never} />
|
||||
<span className="node__name" title={n.name}>
|
||||
{n.name || <span className="muted">unnamed</span>}
|
||||
</span>
|
||||
<span
|
||||
className="chip chip--proto"
|
||||
style={{ ['--hue' as string]: protoHue(n.protocol) } as never}
|
||||
>
|
||||
{n.protocol || '?'}
|
||||
</span>
|
||||
<span className="node__addr">
|
||||
{n.server}
|
||||
{n.port ? `:${n.port}` : ''}
|
||||
</span>
|
||||
{showSource && n.source_name && (
|
||||
<span
|
||||
className="node__src"
|
||||
style={{ ['--hue' as string]: sourceHue(n.source_id ?? 0) } as never}
|
||||
title={n.source_name}
|
||||
>
|
||||
{n.source_name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{nodes.length > shown.length && (
|
||||
<div className="node">
|
||||
<span className="node__idx" />
|
||||
<span className="node__name muted">
|
||||
and {nodes.length - shown.length} more
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import qrcode from 'qrcode-generator'
|
||||
import { IconCheck, IconCopy, IconQr } from './Icons'
|
||||
import { Modal } from './Drawer'
|
||||
import { useToast } from './Toast'
|
||||
import { copy } from '../lib/util'
|
||||
|
||||
function QrSvg({ text, size = 168 }: { text: string; size?: number }) {
|
||||
const path = useMemo(() => {
|
||||
const qr = qrcode(0, 'M')
|
||||
qr.addData(text)
|
||||
qr.make()
|
||||
const n = qr.getModuleCount()
|
||||
let d = ''
|
||||
for (let r = 0; r < n; r++) {
|
||||
for (let c = 0; c < n; c++) {
|
||||
if (qr.isDark(r, c)) d += `M${c} ${r}h1v1h-1z`
|
||||
}
|
||||
}
|
||||
return { d, n }
|
||||
}, [text])
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox={`0 0 ${path.n} ${path.n}`}
|
||||
shapeRendering="crispEdges"
|
||||
role="img"
|
||||
aria-label="Subscription link QR code"
|
||||
>
|
||||
<path d={path.d} fill="#000" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** The public link plus the two things you actually do with it: copy it, or
|
||||
* point a phone at it. The QR lives in a dialog rather than a popover because
|
||||
* the row it sits in is inside a horizontally scrolling table, which would
|
||||
* clip anything anchored to the cell. */
|
||||
export function PublicLink({ url, name }: { url: string; name: string }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [qrOpen, setQrOpen] = useState(false)
|
||||
const toast = useToast()
|
||||
|
||||
useEffect(() => {
|
||||
if (!copied) return
|
||||
const id = window.setTimeout(() => setCopied(false), 1400)
|
||||
return () => window.clearTimeout(id)
|
||||
}, [copied])
|
||||
|
||||
const onCopy = async () => {
|
||||
if (await copy(url)) {
|
||||
setCopied(true)
|
||||
toast.ok(`Copied the link for ${name}`)
|
||||
} else {
|
||||
toast.error('Could not reach the clipboard. Select the link and copy it manually.')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="linkbox">
|
||||
<span className="linkbox__text" title={url}>
|
||||
{url}
|
||||
</span>
|
||||
<button
|
||||
className="ibtn"
|
||||
style={{ width: 22, height: 22 }}
|
||||
onClick={onCopy}
|
||||
aria-label={`Copy the link for ${name}`}
|
||||
title="Copy link"
|
||||
>
|
||||
{copied ? <IconCheck size={13} /> : <IconCopy size={13} />}
|
||||
</button>
|
||||
<button
|
||||
className={`ibtn${qrOpen ? ' is-on' : ''}`}
|
||||
style={{ width: 22, height: 22 }}
|
||||
onClick={() => setQrOpen(true)}
|
||||
aria-label={`Show the QR code for ${name}`}
|
||||
aria-haspopup="dialog"
|
||||
title="QR code"
|
||||
>
|
||||
<IconQr size={13} />
|
||||
</button>
|
||||
</div>
|
||||
<Modal open={qrOpen} title={name} width={320} onClose={() => setQrOpen(false)}>
|
||||
<div className="qr-sheet">
|
||||
<div className="qr-sheet__code">
|
||||
<QrSvg text={url} size={216} />
|
||||
</div>
|
||||
<div className="qr-sheet__url">{url}</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Small copy-to-clipboard button for values shown inline, like an HWID. */
|
||||
export function CopyButton({ value, what }: { value: string; what: string }) {
|
||||
const [done, setDone] = useState(false)
|
||||
const toast = useToast()
|
||||
useEffect(() => {
|
||||
if (!done) return
|
||||
const id = window.setTimeout(() => setDone(false), 1400)
|
||||
return () => window.clearTimeout(id)
|
||||
}, [done])
|
||||
return (
|
||||
<button
|
||||
className="ibtn"
|
||||
style={{ width: 20, height: 20 }}
|
||||
title={`Copy ${what}`}
|
||||
aria-label={`Copy ${what}`}
|
||||
onClick={async () => {
|
||||
if (await copy(value)) {
|
||||
setDone(true)
|
||||
} else {
|
||||
toast.error(`Could not copy the ${what}.`)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{done ? <IconCheck size={12} /> : <IconCopy size={12} />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { IconAlert, IconEmpty, IconRefresh } from './Icons'
|
||||
|
||||
export function EmptyState({
|
||||
title,
|
||||
body,
|
||||
action,
|
||||
}: {
|
||||
title: string
|
||||
body: string
|
||||
action?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="state">
|
||||
<span className="state__glyph">
|
||||
<IconEmpty size={30} />
|
||||
</span>
|
||||
<div className="state__title">{title}</div>
|
||||
<p className="state__body">{body}</p>
|
||||
{action}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ErrorState({
|
||||
message,
|
||||
onRetry,
|
||||
}: {
|
||||
message: string
|
||||
onRetry?: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="state state--error">
|
||||
<span className="state__glyph">
|
||||
<IconAlert size={28} />
|
||||
</span>
|
||||
<div className="state__title">Request failed</div>
|
||||
<p className="state__body">{message}</p>
|
||||
{onRetry && (
|
||||
<button className="btn" onClick={onRetry}>
|
||||
<IconRefresh size={14} />
|
||||
Try again
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Row-shaped placeholders keep the table from jumping when data arrives. */
|
||||
export function TableSkeleton({ rows = 5, cols = 4 }: { rows?: number; cols?: number }) {
|
||||
return (
|
||||
<div style={{ padding: '4px 0' }}>
|
||||
{Array.from({ length: rows }, (_, r) => (
|
||||
<div
|
||||
key={r}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `2fr ${'1fr '.repeat(Math.max(cols - 1, 1))}`,
|
||||
gap: 'var(--s4)',
|
||||
padding: '11px var(--s3)',
|
||||
borderBottom: '1px solid var(--line-soft)',
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: cols }, (_, c) => (
|
||||
<div
|
||||
key={c}
|
||||
className="skel"
|
||||
style={{ width: `${[82, 54, 66, 40, 70][(r + c) % 5]}%`, opacity: 1 - r * 0.13 }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Loading({ label = 'Loading' }: { label?: string }) {
|
||||
return (
|
||||
<div className="state">
|
||||
<span className="spinner" />
|
||||
<div className="state__body">{label}…</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { IconAlert, IconCheck, IconInfo } from './Icons'
|
||||
|
||||
type Kind = 'ok' | 'error' | 'info'
|
||||
|
||||
interface Toast {
|
||||
id: number
|
||||
kind: Kind
|
||||
text: string
|
||||
}
|
||||
|
||||
interface ToastApi {
|
||||
ok: (text: string) => void
|
||||
error: (text: string) => void
|
||||
info: (text: string) => void
|
||||
}
|
||||
|
||||
const Ctx = createContext<ToastApi | null>(null)
|
||||
|
||||
export function useToast(): ToastApi {
|
||||
const api = useContext(Ctx)
|
||||
if (!api) throw new Error('useToast used outside ToastProvider')
|
||||
return api
|
||||
}
|
||||
|
||||
const HUE: Record<Kind, string> = {
|
||||
ok: 'var(--ok)',
|
||||
error: 'var(--danger)',
|
||||
info: 'var(--beam)',
|
||||
}
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([])
|
||||
const seq = useRef(0)
|
||||
|
||||
const push = useCallback((kind: Kind, text: string) => {
|
||||
const id = ++seq.current
|
||||
setToasts((t) => [...t.slice(-3), { id, kind, text }])
|
||||
window.setTimeout(
|
||||
() => setToasts((t) => t.filter((x) => x.id !== id)),
|
||||
kind === 'error' ? 6500 : 3500,
|
||||
)
|
||||
}, [])
|
||||
|
||||
const api = useMemo<ToastApi>(
|
||||
() => ({
|
||||
ok: (t) => push('ok', t),
|
||||
error: (t) => push('error', t),
|
||||
info: (t) => push('info', t),
|
||||
}),
|
||||
[push],
|
||||
)
|
||||
|
||||
const dismiss = (id: number) => setToasts((t) => t.filter((x) => x.id !== id))
|
||||
|
||||
return (
|
||||
<Ctx.Provider value={api}>
|
||||
{children}
|
||||
{createPortal(
|
||||
<div className="toasts" role="status" aria-live="polite">
|
||||
{toasts.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className="toast"
|
||||
style={{ ['--hue' as string]: HUE[t.kind] }}
|
||||
onClick={() => dismiss(t.id)}
|
||||
>
|
||||
<span className="toast__icon">
|
||||
{t.kind === 'ok' ? (
|
||||
<IconCheck size={14} />
|
||||
) : t.kind === 'error' ? (
|
||||
<IconAlert size={14} />
|
||||
) : (
|
||||
<IconInfo size={14} />
|
||||
)}
|
||||
</span>
|
||||
<span className="toast__text">{t.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</Ctx.Provider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import type {
|
||||
AccessState,
|
||||
Client,
|
||||
ClientQuery,
|
||||
FormatOption,
|
||||
Group,
|
||||
LoginResult,
|
||||
Page,
|
||||
PreviewResult,
|
||||
Source,
|
||||
Stats,
|
||||
TestResult,
|
||||
} from './types'
|
||||
|
||||
const TOKEN_KEY = 'prizma.token'
|
||||
|
||||
/** The API is always mounted at the server root, next to /sub — see CONTRACT.md. */
|
||||
const BASE = ''
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number
|
||||
constructor(status: number, message: string) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
export const token = {
|
||||
get: () => localStorage.getItem(TOKEN_KEY),
|
||||
set: (t: string) => localStorage.setItem(TOKEN_KEY, t),
|
||||
clear: () => localStorage.removeItem(TOKEN_KEY),
|
||||
}
|
||||
|
||||
/** Fired on any 401 so the shell can bounce to the login screen from anywhere. */
|
||||
export const UNAUTHORIZED = 'prizma:unauthorized'
|
||||
|
||||
async function request<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = { Accept: 'application/json' }
|
||||
const t = token.get()
|
||||
if (t) headers.Authorization = `Bearer ${t}`
|
||||
if (body !== undefined) headers['Content-Type'] = 'application/json'
|
||||
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(BASE + path, {
|
||||
method,
|
||||
headers,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
})
|
||||
} catch {
|
||||
throw new ApiError(0, 'Cannot reach the server. Check that Prizma is running.')
|
||||
}
|
||||
|
||||
if (res.status === 401) {
|
||||
token.clear()
|
||||
window.dispatchEvent(new CustomEvent(UNAUTHORIZED))
|
||||
throw new ApiError(401, 'Session expired. Sign in again.')
|
||||
}
|
||||
|
||||
const text = await res.text()
|
||||
let parsed: unknown = null
|
||||
if (text) {
|
||||
try {
|
||||
parsed = JSON.parse(text)
|
||||
} catch {
|
||||
parsed = text
|
||||
}
|
||||
}
|
||||
|
||||
if (!res.ok) throw new ApiError(res.status, errorMessage(parsed, res.status))
|
||||
return parsed as T
|
||||
}
|
||||
|
||||
function errorMessage(parsed: unknown, status: number): string {
|
||||
if (typeof parsed === 'string' && parsed.trim()) return parsed.trim()
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
const o = parsed as Record<string, unknown>
|
||||
for (const k of ['error', 'message', 'msg', 'detail']) {
|
||||
const v = o[k]
|
||||
if (typeof v === 'string' && v.trim()) return v.trim()
|
||||
}
|
||||
}
|
||||
return `Request failed (HTTP ${status})`
|
||||
}
|
||||
|
||||
/** The list envelope is not pinned by the contract, so accept the usual shapes. */
|
||||
function unwrapList<T>(raw: unknown): Page<T> {
|
||||
if (Array.isArray(raw)) return { items: raw as T[], total: raw.length }
|
||||
if (raw && typeof raw === 'object') {
|
||||
const o = raw as Record<string, unknown>
|
||||
for (const k of ['items', 'clients', 'rows', 'data', 'results']) {
|
||||
if (Array.isArray(o[k])) {
|
||||
const items = o[k] as T[]
|
||||
const total = typeof o.total === 'number' ? o.total : items.length
|
||||
return { items, total }
|
||||
}
|
||||
}
|
||||
}
|
||||
return { items: [], total: 0 }
|
||||
}
|
||||
|
||||
function arrayOf<T>(raw: unknown, key: string): T[] {
|
||||
if (Array.isArray(raw)) return raw as T[]
|
||||
if (raw && typeof raw === 'object') {
|
||||
const v = (raw as Record<string, unknown>)[key]
|
||||
if (Array.isArray(v)) return v as T[]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- auth
|
||||
|
||||
export const api = {
|
||||
login: (username: string, password: string) =>
|
||||
request<LoginResult>('POST', '/api/auth/login', { username, password }),
|
||||
|
||||
me: () => request<{ username?: string; user?: string }>('GET', '/api/auth/me'),
|
||||
|
||||
stats: () => request<Stats>('GET', '/api/stats'),
|
||||
|
||||
access: () => request<AccessState>('GET', '/api/access'),
|
||||
|
||||
// ------------------------------------------------------------- sources
|
||||
|
||||
listSources: async () =>
|
||||
arrayOf<Source>(await request<unknown>('GET', '/api/sources'), 'sources'),
|
||||
|
||||
createSource: (s: Partial<Source>) => request<Source>('POST', '/api/sources', s),
|
||||
|
||||
updateSource: (id: number, s: Partial<Source>) =>
|
||||
request<Source>('PUT', `/api/sources/${id}`, s),
|
||||
|
||||
deleteSource: (id: number) => request<void>('DELETE', `/api/sources/${id}`),
|
||||
|
||||
testSource: (id: number) =>
|
||||
request<TestResult>('POST', `/api/sources/${id}/test`, {}),
|
||||
|
||||
rotateHwid: (id: number) =>
|
||||
request<Source | { hwid: string }>('POST', `/api/sources/${id}/rotate-hwid`, {}),
|
||||
|
||||
// -------------------------------------------------------------- groups
|
||||
|
||||
listGroups: async () =>
|
||||
arrayOf<Group>(await request<unknown>('GET', '/api/groups'), 'groups'),
|
||||
|
||||
createGroup: (g: Partial<Group>) => request<Group>('POST', '/api/groups', g),
|
||||
|
||||
updateGroup: (id: number, g: Partial<Group>) =>
|
||||
request<Group>('PUT', `/api/groups/${id}`, g),
|
||||
|
||||
deleteGroup: (id: number) => request<void>('DELETE', `/api/groups/${id}`),
|
||||
|
||||
// The draft is posted as the body so the preview reflects unsaved edits when
|
||||
// the backend honours it; a backend that previews the stored group instead
|
||||
// still answers correctly, just without the pending changes.
|
||||
previewGroup: (id: number, draft?: Partial<Group>) =>
|
||||
request<PreviewResult>('POST', `/api/groups/${id}/preview`, draft ?? {}),
|
||||
|
||||
// ------------------------------------------------------------- clients
|
||||
|
||||
listClients: async (q: ClientQuery): Promise<Page<Client>> => {
|
||||
const p = new URLSearchParams()
|
||||
if (q.search) p.set('search', q.search)
|
||||
if (q.banned !== undefined) p.set('banned', String(q.banned))
|
||||
if (q.allowed !== undefined) p.set('allowed', String(q.allowed))
|
||||
p.set('limit', String(q.limit))
|
||||
p.set('offset', String(q.offset))
|
||||
p.set('sort', q.sort)
|
||||
return unwrapList<Client>(await request<unknown>('GET', `/api/clients?${p}`))
|
||||
},
|
||||
|
||||
banClient: (id: number, banned: boolean) =>
|
||||
request<void>('POST', `/api/clients/${id}/${banned ? 'ban' : 'unban'}`, {}),
|
||||
|
||||
allowClient: (id: number, allowed: boolean) =>
|
||||
request<void>('POST', `/api/clients/${id}/${allowed ? 'allow' : 'disallow'}`, {}),
|
||||
|
||||
labelClient: (id: number, label: string) =>
|
||||
request<void>('POST', `/api/clients/${id}/label`, { label }),
|
||||
|
||||
deleteClient: (id: number) => request<void>('DELETE', `/api/clients/${id}`),
|
||||
|
||||
// ------------------------------------------------------------- formats
|
||||
|
||||
formats: async (): Promise<FormatOption[]> => {
|
||||
const raw = await request<unknown>('GET', '/api/formats')
|
||||
const list = arrayOf<unknown>(raw, 'formats')
|
||||
const out: FormatOption[] = []
|
||||
for (const item of list) {
|
||||
if (typeof item === 'string') out.push({ value: item, label: item })
|
||||
else if (item && typeof item === 'object') {
|
||||
const o = item as Record<string, unknown>
|
||||
const value = String(o.value ?? o.id ?? o.format ?? o.name ?? '')
|
||||
if (!value) continue
|
||||
out.push({ value, label: String(o.label ?? o.title ?? o.name ?? value) })
|
||||
}
|
||||
}
|
||||
return out
|
||||
},
|
||||
}
|
||||
|
||||
/** Fallback list so the format picker still works if /api/formats is unavailable. */
|
||||
export const FALLBACK_FORMATS: FormatOption[] = [
|
||||
{ value: 'base64', label: 'base64' },
|
||||
{ value: 'uri', label: 'uri' },
|
||||
{ value: 'clash', label: 'clash' },
|
||||
{ value: 'singbox', label: 'singbox' },
|
||||
{ value: 'xray', label: 'xray' },
|
||||
]
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
// Hash routing, deliberately: the bundle is embedded in the Go binary with
|
||||
// base './' and may be mounted under any path, and a hash route needs no
|
||||
// server-side rewrite to survive a reload.
|
||||
|
||||
export function currentPath(): string {
|
||||
const h = window.location.hash.replace(/^#/, '')
|
||||
return h.startsWith('/') ? h : '/'
|
||||
}
|
||||
|
||||
export function navigate(path: string): void {
|
||||
if (currentPath() === path) return
|
||||
window.location.hash = path
|
||||
}
|
||||
|
||||
export function useRoute(): string {
|
||||
const [path, setPath] = useState(currentPath)
|
||||
useEffect(() => {
|
||||
const onHash = () => setPath(currentPath())
|
||||
window.addEventListener('hashchange', onHash)
|
||||
// Normalise a bare load so the address bar always shows a real route.
|
||||
if (!window.location.hash) window.location.replace('#/')
|
||||
return () => window.removeEventListener('hashchange', onHash)
|
||||
}, [])
|
||||
return path
|
||||
}
|
||||
|
||||
export const href = (path: string) => `#${path}`
|
||||
@@ -0,0 +1,60 @@
|
||||
// The prism made functional: every source and every protocol is assigned a
|
||||
// stop on the same seven-colour ramp, so colour in this panel always answers
|
||||
// "which beam is this?" rather than decorating a surface.
|
||||
|
||||
export const SPECTRUM = [
|
||||
'var(--sp-0)',
|
||||
'var(--sp-1)',
|
||||
'var(--sp-2)',
|
||||
'var(--sp-3)',
|
||||
'var(--sp-4)',
|
||||
'var(--sp-5)',
|
||||
'var(--sp-6)',
|
||||
] as const
|
||||
|
||||
/** Stable hue for a source: same id always gets the same colour, everywhere. */
|
||||
export function sourceHue(id: number): string {
|
||||
if (!id) return 'var(--ink-faint)'
|
||||
// Multiply before the modulo so consecutive ids land far apart on the ramp.
|
||||
return SPECTRUM[(Math.abs(id) * 3) % SPECTRUM.length]
|
||||
}
|
||||
|
||||
/** Protocols are ordered by how they travel, not alphabetically: the TCP-ish
|
||||
* ones sit at the warm end, the UDP-ish ones at the cool end. */
|
||||
const PROTO_STOP: Record<string, number> = {
|
||||
vless: 5,
|
||||
vmess: 6,
|
||||
trojan: 4,
|
||||
ss: 3,
|
||||
ssr: 3,
|
||||
hysteria: 1,
|
||||
hysteria2: 1,
|
||||
tuic: 2,
|
||||
anytls: 4,
|
||||
wireguard: 0,
|
||||
socks: 0,
|
||||
http: 0,
|
||||
ssh: 0,
|
||||
}
|
||||
|
||||
export function protoHue(proto: string): string {
|
||||
const stop = PROTO_STOP[proto?.toLowerCase?.() ?? '']
|
||||
return stop === undefined ? 'var(--ink-mute)' : SPECTRUM[stop]
|
||||
}
|
||||
|
||||
/** The allow-list offered by the protocol multi-select, in ramp order. */
|
||||
export const PROTOCOLS = [
|
||||
'vless',
|
||||
'vmess',
|
||||
'trojan',
|
||||
'ss',
|
||||
'ssr',
|
||||
'hysteria',
|
||||
'hysteria2',
|
||||
'tuic',
|
||||
'anytls',
|
||||
'wireguard',
|
||||
'socks',
|
||||
'http',
|
||||
'ssh',
|
||||
] as const
|
||||
@@ -0,0 +1,214 @@
|
||||
// Mirrors internal/model. Field names track the Go json tags exactly; anything
|
||||
// the backend adds later arrives as an ignored extra key rather than a break.
|
||||
|
||||
export interface Node {
|
||||
name: string
|
||||
protocol: string
|
||||
server: string
|
||||
port: number
|
||||
uuid?: string
|
||||
password?: string
|
||||
method?: string
|
||||
alter_id?: number
|
||||
network?: string
|
||||
path?: string
|
||||
host?: string
|
||||
service_name?: string
|
||||
header_type?: string
|
||||
seed?: string
|
||||
security?: string
|
||||
sni?: string
|
||||
alpn?: string[]
|
||||
fingerprint?: string
|
||||
public_key?: string
|
||||
short_id?: string
|
||||
spider_x?: string
|
||||
flow?: string
|
||||
allow_insecure?: boolean
|
||||
obfs?: string
|
||||
obfs_password?: string
|
||||
up_mbps?: number
|
||||
down_mbps?: number
|
||||
congestion?: string
|
||||
extra?: Record<string, unknown>
|
||||
source_id?: number
|
||||
source_name?: string
|
||||
}
|
||||
|
||||
export interface Source {
|
||||
id: number
|
||||
name: string
|
||||
url: string
|
||||
hwid: string
|
||||
device_os: string
|
||||
ver_os: string
|
||||
device_model: string
|
||||
user_agent: string
|
||||
forward_client_ua: boolean
|
||||
extra_headers?: Record<string, string>
|
||||
cache_ttl: number
|
||||
enabled: boolean
|
||||
token: string
|
||||
note?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
last_fetch_at?: string
|
||||
last_fetch_ok: boolean
|
||||
last_fetch_error?: string
|
||||
last_node_count: number
|
||||
hits: number
|
||||
}
|
||||
|
||||
export interface Filter {
|
||||
include_name?: string
|
||||
exclude_name?: string
|
||||
include_content?: string
|
||||
exclude_content?: string
|
||||
protocols?: string[]
|
||||
case_sensitive?: boolean
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export interface GroupMember {
|
||||
source_id: number
|
||||
name?: string
|
||||
prefix?: string
|
||||
filter: Filter
|
||||
}
|
||||
|
||||
export interface Group {
|
||||
id: number
|
||||
name: string
|
||||
token: string
|
||||
members: GroupMember[]
|
||||
filter: Filter
|
||||
output_format: string
|
||||
name_template: string
|
||||
dedupe: boolean
|
||||
enabled: boolean
|
||||
note?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
hits: number
|
||||
}
|
||||
|
||||
export interface Client {
|
||||
id: number
|
||||
hwid: string
|
||||
synthetic: boolean
|
||||
user_agent: string
|
||||
ip: string
|
||||
device_os?: string
|
||||
last_link?: string
|
||||
label?: string
|
||||
banned: boolean
|
||||
allowed: boolean
|
||||
hits: number
|
||||
first_seen: string
|
||||
last_seen: string
|
||||
last_status: number
|
||||
}
|
||||
|
||||
export interface Stats {
|
||||
sources: number
|
||||
groups: number
|
||||
clients: number
|
||||
banned_clients: number
|
||||
whitelisted: number
|
||||
requests_24h: number
|
||||
total_requests: number
|
||||
}
|
||||
|
||||
export interface AccessState {
|
||||
whitelist_active: boolean
|
||||
whitelisted: number
|
||||
banned: number
|
||||
}
|
||||
|
||||
export interface TestResult {
|
||||
ok: boolean
|
||||
format: string
|
||||
nodes: Node[] | null
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface PreviewResult {
|
||||
format: string
|
||||
count: number
|
||||
nodes: Node[] | null
|
||||
}
|
||||
|
||||
export interface FormatOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface LoginResult {
|
||||
token: string
|
||||
expires_at?: string
|
||||
}
|
||||
|
||||
export type ClientSort = 'last_seen' | 'first_seen' | 'hits'
|
||||
|
||||
export interface ClientQuery {
|
||||
search?: string
|
||||
banned?: boolean
|
||||
allowed?: boolean
|
||||
limit: number
|
||||
offset: number
|
||||
sort: ClientSort
|
||||
}
|
||||
|
||||
export interface Page<T> {
|
||||
items: T[]
|
||||
total: number
|
||||
}
|
||||
|
||||
/** Blank drafts — what an unsaved form starts from. */
|
||||
export const emptyFilter = (): Filter => ({
|
||||
include_name: '',
|
||||
exclude_name: '',
|
||||
include_content: '',
|
||||
exclude_content: '',
|
||||
protocols: [],
|
||||
case_sensitive: false,
|
||||
limit: 0,
|
||||
})
|
||||
|
||||
export const emptySource = (): Source => ({
|
||||
id: 0,
|
||||
name: '',
|
||||
url: '',
|
||||
hwid: '',
|
||||
device_os: '',
|
||||
ver_os: '',
|
||||
device_model: '',
|
||||
user_agent: '',
|
||||
forward_client_ua: true,
|
||||
extra_headers: {},
|
||||
cache_ttl: 0,
|
||||
enabled: true,
|
||||
token: '',
|
||||
note: '',
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
last_fetch_ok: false,
|
||||
last_node_count: 0,
|
||||
hits: 0,
|
||||
})
|
||||
|
||||
export const emptyGroup = (): Group => ({
|
||||
id: 0,
|
||||
name: '',
|
||||
token: '',
|
||||
members: [],
|
||||
filter: emptyFilter(),
|
||||
output_format: '',
|
||||
name_template: '',
|
||||
dedupe: true,
|
||||
enabled: true,
|
||||
note: '',
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
hits: 0,
|
||||
})
|
||||
@@ -0,0 +1,186 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
/** 32 lowercase hex chars — the HWID shape Remnawave and Happ use. */
|
||||
export function newHwid(): string {
|
||||
const b = new Uint8Array(16)
|
||||
crypto.getRandomValues(b)
|
||||
return Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
export function publicLink(token: string): string {
|
||||
if (!token) return ''
|
||||
return `${window.location.origin}/sub/${token}`
|
||||
}
|
||||
|
||||
export async function copy(text: string): Promise<boolean> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
return true
|
||||
} catch {
|
||||
// Clipboard API needs a secure context; panels are often on plain http.
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = text
|
||||
ta.setAttribute('readonly', '')
|
||||
ta.style.position = 'fixed'
|
||||
ta.style.opacity = '0'
|
||||
document.body.appendChild(ta)
|
||||
ta.select()
|
||||
let ok = false
|
||||
try {
|
||||
ok = document.execCommand('copy')
|
||||
} catch {
|
||||
ok = false
|
||||
}
|
||||
document.body.removeChild(ta)
|
||||
return ok
|
||||
}
|
||||
}
|
||||
|
||||
const RELATIVE: [number, Intl.RelativeTimeFormatUnit][] = [
|
||||
[60, 'second'],
|
||||
[3600, 'minute'],
|
||||
[86400, 'hour'],
|
||||
[604800, 'day'],
|
||||
[2629800, 'week'],
|
||||
[31557600, 'month'],
|
||||
]
|
||||
|
||||
// The panel is English throughout, so times and numbers are formatted in
|
||||
// English too — a Russian "через 2 часа" next to an English column header reads
|
||||
// like a bug. Absolute timestamps still use the machine locale.
|
||||
const LOCALE = 'en'
|
||||
|
||||
const rtf = new Intl.RelativeTimeFormat(LOCALE, { numeric: 'auto' })
|
||||
|
||||
export function timeAgo(iso?: string | null): string {
|
||||
if (!iso) return 'never'
|
||||
const t = Date.parse(iso)
|
||||
if (Number.isNaN(t)) return 'never'
|
||||
// Zero timestamps come back from Go as year 1; they mean "no value".
|
||||
if (t < Date.UTC(1980, 0, 1)) return 'never'
|
||||
const diff = (t - Date.now()) / 1000
|
||||
const abs = Math.abs(diff)
|
||||
if (abs < 10) return 'just now'
|
||||
let prev = 1
|
||||
for (const [limit, unit] of RELATIVE) {
|
||||
if (abs < limit) return rtf.format(Math.round(diff / prev), unit)
|
||||
prev = limit
|
||||
}
|
||||
return rtf.format(Math.round(diff / 31557600), 'year')
|
||||
}
|
||||
|
||||
export function absTime(iso?: string | null): string {
|
||||
if (!iso) return ''
|
||||
const t = Date.parse(iso)
|
||||
if (Number.isNaN(t) || t < Date.UTC(1980, 0, 1)) return ''
|
||||
return new Date(t).toLocaleString()
|
||||
}
|
||||
|
||||
export function compactNumber(n: number): string {
|
||||
if (!Number.isFinite(n)) return '0'
|
||||
if (Math.abs(n) < 10000) return n.toLocaleString(LOCALE)
|
||||
return new Intl.NumberFormat(LOCALE, {
|
||||
notation: 'compact',
|
||||
maximumFractionDigits: 1,
|
||||
}).format(n)
|
||||
}
|
||||
|
||||
export function truncMiddle(s: string, head = 10, tail = 6): string {
|
||||
if (s.length <= head + tail + 1) return s
|
||||
return `${s.slice(0, head)}…${s.slice(-tail)}`
|
||||
}
|
||||
|
||||
/** Pre-flight check for a filter pattern before it reaches the Go side.
|
||||
*
|
||||
* Two failure modes matter: a plain syntax typo, which the JS engine catches,
|
||||
* and a construct JS accepts but Go's RE2 rejects outright — lookaround and
|
||||
* backreferences. Catching the second here turns a confusing save error into
|
||||
* an inline hint. */
|
||||
export function regexError(pattern: string): string | null {
|
||||
if (!pattern) return null
|
||||
try {
|
||||
new RegExp(pattern)
|
||||
} catch (e) {
|
||||
return e instanceof Error
|
||||
? e.message.replace(/^Invalid regular expression:.*?:\s*/, '')
|
||||
: 'not a valid pattern'
|
||||
}
|
||||
if (/\(\?[=!<]/.test(pattern.replace(/\(\?<[A-Za-z_]\w*>/g, ''))) {
|
||||
return 'Lookahead and lookbehind are not supported.'
|
||||
}
|
||||
if (/\\[1-9]/.test(pattern)) return 'Backreferences are not supported.'
|
||||
return null
|
||||
}
|
||||
|
||||
export function useDebounced<T>(value: T, ms = 300): T {
|
||||
const [v, setV] = useState(value)
|
||||
useEffect(() => {
|
||||
const id = window.setTimeout(() => setV(value), ms)
|
||||
return () => window.clearTimeout(id)
|
||||
}, [value, ms])
|
||||
return v
|
||||
}
|
||||
|
||||
/** Escape-to-dismiss, shared by the drawer and the modal. */
|
||||
export function useEscape(enabled: boolean, onEscape: () => void): void {
|
||||
const cb = useRef(onEscape)
|
||||
cb.current = onEscape
|
||||
useEffect(() => {
|
||||
if (!enabled) return
|
||||
const h = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation()
|
||||
cb.current()
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', h)
|
||||
return () => document.removeEventListener('keydown', h)
|
||||
}, [enabled])
|
||||
}
|
||||
|
||||
export interface Async<T> {
|
||||
data: T | null
|
||||
error: string | null
|
||||
loading: boolean
|
||||
reload: () => void
|
||||
}
|
||||
|
||||
/** One loader for every page: data, error and a manual reload, nothing more. */
|
||||
export function useAsync<T>(fn: () => Promise<T>, deps: unknown[]): Async<T> {
|
||||
const [data, setData] = useState<T | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [nonce, setNonce] = useState(0)
|
||||
const fnRef = useRef(fn)
|
||||
fnRef.current = fn
|
||||
|
||||
useEffect(() => {
|
||||
let live = true
|
||||
setLoading(true)
|
||||
fnRef
|
||||
.current()
|
||||
.then((d) => {
|
||||
if (!live) return
|
||||
setData(d)
|
||||
setError(null)
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!live) return
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
})
|
||||
.finally(() => {
|
||||
if (live) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
live = false
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [...deps, nonce])
|
||||
|
||||
const reload = useCallback(() => setNonce((n) => n + 1), [])
|
||||
return { data, error, loading, reload }
|
||||
}
|
||||
|
||||
export function cx(...parts: (string | false | null | undefined)[]): string {
|
||||
return parts.filter(Boolean).join(' ')
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './styles/tokens.css'
|
||||
import './styles/base.css'
|
||||
import './styles/layout.css'
|
||||
import './styles/components.css'
|
||||
import './styles/pages.css'
|
||||
import { App } from './App'
|
||||
|
||||
const root = document.getElementById('root')
|
||||
if (!root) throw new Error('#root is missing from index.html')
|
||||
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,417 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { api } from '../lib/api'
|
||||
import type { AccessState, Client, ClientSort } from '../lib/types'
|
||||
import {
|
||||
absTime,
|
||||
compactNumber,
|
||||
cx,
|
||||
timeAgo,
|
||||
truncMiddle,
|
||||
useAsync,
|
||||
useDebounced,
|
||||
} from '../lib/util'
|
||||
import { PageHeader } from '../components/Layout'
|
||||
import { Confirm } from '../components/Drawer'
|
||||
import { Segmented } from '../components/Form'
|
||||
import { CopyButton } from '../components/PublicLink'
|
||||
import { EmptyState, ErrorState, TableSkeleton } from '../components/States'
|
||||
import { useToast } from '../components/Toast'
|
||||
import {
|
||||
IconAlert,
|
||||
IconBan,
|
||||
IconCheck,
|
||||
IconChevronLeft,
|
||||
IconChevronRight,
|
||||
IconRefresh,
|
||||
IconSearch,
|
||||
IconShield,
|
||||
IconShieldOff,
|
||||
IconTrash,
|
||||
} from '../components/Icons'
|
||||
|
||||
type Scope = 'all' | 'banned' | 'allowed'
|
||||
|
||||
const PAGE_SIZE = 25
|
||||
|
||||
export function Clients() {
|
||||
const [search, setSearch] = useState('')
|
||||
const [scope, setScope] = useState<Scope>('all')
|
||||
const [sort, setSort] = useState<ClientSort>('last_seen')
|
||||
const [offset, setOffset] = useState(0)
|
||||
const debounced = useDebounced(search, 300)
|
||||
const toast = useToast()
|
||||
|
||||
// A new search or scope always starts at the first page.
|
||||
useEffect(() => setOffset(0), [debounced, scope, sort])
|
||||
|
||||
const load = useAsync(async () => {
|
||||
const [page, access] = await Promise.all([
|
||||
api.listClients({
|
||||
search: debounced || undefined,
|
||||
banned: scope === 'banned' ? true : undefined,
|
||||
allowed: scope === 'allowed' ? true : undefined,
|
||||
limit: PAGE_SIZE,
|
||||
offset,
|
||||
sort,
|
||||
}),
|
||||
api.access(),
|
||||
])
|
||||
return { page, access }
|
||||
}, [debounced, scope, sort, offset])
|
||||
|
||||
const [deleting, setDeleting] = useState<Client | null>(null)
|
||||
const [busyDelete, setBusyDelete] = useState(false)
|
||||
|
||||
const act = async (fn: () => Promise<unknown>, done: string) => {
|
||||
try {
|
||||
await fn()
|
||||
toast.ok(done)
|
||||
load.reload()
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
}
|
||||
|
||||
const clients = load.data?.page.items ?? []
|
||||
const total = load.data?.page.total ?? 0
|
||||
const access: AccessState | undefined = load.data?.access
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
eyebrow="Access"
|
||||
title="Clients"
|
||||
actions={
|
||||
<button className="btn" onClick={load.reload} disabled={load.loading}>
|
||||
{load.loading ? <span className="spinner" /> : <IconRefresh size={14} />}
|
||||
Refresh
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="page">
|
||||
<div className={cx('banner', access?.whitelist_active ? 'banner--locked' : 'banner--open')}>
|
||||
<span className="banner__icon">
|
||||
{access?.whitelist_active ? <IconShield size={18} /> : <IconAlert size={18} />}
|
||||
</span>
|
||||
<span className="banner__text">
|
||||
<span className="banner__title">
|
||||
{access?.whitelist_active
|
||||
? 'Whitelist active — only allowed devices get in'
|
||||
: 'Whitelist empty — open to everyone'}
|
||||
</span>
|
||||
<span className="banner__body">
|
||||
{access?.whitelist_active ? (
|
||||
<>
|
||||
{access.whitelisted} device{access.whitelisted === 1 ? '' : 's'} may fetch
|
||||
your links; every other device gets 403, banned or not. Disallow them all
|
||||
to reopen the links.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Nothing is whitelisted, so any device holding a link can use it — except
|
||||
the {access?.banned ?? 0} banned one{access?.banned === 1 ? '' : 's'}.
|
||||
Allowing a single device switches the whitelist on and locks out
|
||||
everyone else.
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="toolbar">
|
||||
<div className="search" style={{ width: 300 }}>
|
||||
<span className="search__icon">
|
||||
<IconSearch size={14} />
|
||||
</span>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Search HWID, User-Agent, IP or label"
|
||||
aria-label="Search clients"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Segmented<Scope>
|
||||
ariaLabel="Filter by access"
|
||||
value={scope}
|
||||
onChange={setScope}
|
||||
options={[
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'allowed', label: 'Allowed' },
|
||||
{ value: 'banned', label: 'Banned' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Segmented<ClientSort>
|
||||
ariaLabel="Sort"
|
||||
value={sort}
|
||||
onChange={setSort}
|
||||
options={[
|
||||
{ value: 'last_seen', label: 'Last seen' },
|
||||
{ value: 'first_seen', label: 'First seen' },
|
||||
{ value: 'hits', label: 'Hits' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="toolbar__spacer" />
|
||||
<span className="label">{compactNumber(total)} devices</span>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card__body card__body--flush">
|
||||
{load.error && !load.data ? (
|
||||
<ErrorState message={load.error} onRetry={load.reload} />
|
||||
) : load.loading && !load.data ? (
|
||||
<TableSkeleton rows={6} cols={6} />
|
||||
) : clients.length === 0 ? (
|
||||
<EmptyState
|
||||
title={debounced || scope !== 'all' ? 'Nothing matches' : 'No devices yet'}
|
||||
body={
|
||||
debounced || scope !== 'all'
|
||||
? 'No device matches the current search and filter.'
|
||||
: 'Every device that fetches one of your links is recorded here, keyed by the HWID it presented.'
|
||||
}
|
||||
action={
|
||||
debounced || scope !== 'all' ? (
|
||||
<button
|
||||
className="btn"
|
||||
onClick={() => {
|
||||
setSearch('')
|
||||
setScope('all')
|
||||
}}
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="tbl-wrap">
|
||||
<table className="tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>HWID</th>
|
||||
<th>Label</th>
|
||||
<th>Client</th>
|
||||
<th>IP</th>
|
||||
<th className="num">Hits</th>
|
||||
<th>First seen</th>
|
||||
<th>Last seen</th>
|
||||
<th className="actions">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{clients.map((c) => (
|
||||
<tr key={c.id}>
|
||||
<td>
|
||||
<div className="hwid">
|
||||
<span className="hwid__text" title={c.hwid}>
|
||||
{truncMiddle(c.hwid, 12, 6)}
|
||||
</span>
|
||||
<CopyButton value={c.hwid} what="HWID" />
|
||||
{c.synthetic && (
|
||||
<span
|
||||
className="chip chip--mute"
|
||||
title="No x-hwid header; derived from User-Agent and IP"
|
||||
>
|
||||
synthetic
|
||||
</span>
|
||||
)}
|
||||
{c.banned && <span className="chip chip--danger">banned</span>}
|
||||
{c.allowed && <span className="chip chip--ok">allowed</span>}
|
||||
</div>
|
||||
</td>
|
||||
<td className="label-cell">
|
||||
<LabelCell
|
||||
client={c}
|
||||
onSave={(label) =>
|
||||
act(
|
||||
() => api.labelClient(c.id, label),
|
||||
label ? `Labelled ${label}` : 'Label removed',
|
||||
)
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td className="ua-cell" title={c.user_agent}>
|
||||
{c.user_agent || '—'}
|
||||
</td>
|
||||
<td className="mono muted">{c.ip || '—'}</td>
|
||||
<td className="num">{compactNumber(c.hits)}</td>
|
||||
<td className="mono muted" title={absTime(c.first_seen)}>
|
||||
{timeAgo(c.first_seen)}
|
||||
</td>
|
||||
<td className="mono muted" title={absTime(c.last_seen)}>
|
||||
{timeAgo(c.last_seen)}
|
||||
</td>
|
||||
<td className="actions">
|
||||
<button
|
||||
className={cx('ibtn', c.allowed && 'is-on')}
|
||||
title={
|
||||
c.allowed
|
||||
? 'Remove from the whitelist'
|
||||
: 'Add to the whitelist'
|
||||
}
|
||||
aria-label={
|
||||
c.allowed
|
||||
? `Disallow ${c.hwid}`
|
||||
: `Allow ${c.hwid}`
|
||||
}
|
||||
onClick={() =>
|
||||
act(
|
||||
() => api.allowClient(c.id, !c.allowed),
|
||||
c.allowed ? 'Removed from the whitelist' : 'Added to the whitelist',
|
||||
)
|
||||
}
|
||||
>
|
||||
{c.allowed ? <IconShieldOff size={14} /> : <IconShield size={14} />}
|
||||
</button>
|
||||
<button
|
||||
className={cx('ibtn', c.banned ? 'is-on' : 'is-danger')}
|
||||
title={c.banned ? 'Unban' : 'Ban'}
|
||||
aria-label={c.banned ? `Unban ${c.hwid}` : `Ban ${c.hwid}`}
|
||||
onClick={() =>
|
||||
act(
|
||||
() => api.banClient(c.id, !c.banned),
|
||||
c.banned ? 'Unbanned' : 'Banned',
|
||||
)
|
||||
}
|
||||
>
|
||||
{c.banned ? <IconCheck size={14} /> : <IconBan size={14} />}
|
||||
</button>
|
||||
<button
|
||||
className="ibtn is-danger"
|
||||
title="Delete"
|
||||
aria-label={`Delete ${c.hwid}`}
|
||||
onClick={() => setDeleting(c)}
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="pager">
|
||||
<span className="pager__pos">
|
||||
{offset + 1}–{Math.min(offset + clients.length, total)} of{' '}
|
||||
{compactNumber(total)}
|
||||
</span>
|
||||
<span className="pager__spacer" />
|
||||
<button
|
||||
className="btn btn--sm"
|
||||
disabled={offset === 0 || load.loading}
|
||||
onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}
|
||||
>
|
||||
<IconChevronLeft size={13} />
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--sm"
|
||||
disabled={offset + clients.length >= total || load.loading}
|
||||
onClick={() => setOffset(offset + PAGE_SIZE)}
|
||||
>
|
||||
Next
|
||||
<IconChevronRight size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Confirm
|
||||
open={!!deleting}
|
||||
title="Delete this device?"
|
||||
body={
|
||||
<>
|
||||
The record for <strong>{deleting ? truncMiddle(deleting.hwid, 12, 6) : ''}</strong>{' '}
|
||||
is removed, along with its ban and whitelist state. It comes back the next time
|
||||
the device fetches a link.
|
||||
</>
|
||||
}
|
||||
confirmLabel="Delete device"
|
||||
destructive
|
||||
busy={busyDelete}
|
||||
onCancel={() => setDeleting(null)}
|
||||
onConfirm={async () => {
|
||||
if (!deleting) return
|
||||
setBusyDelete(true)
|
||||
try {
|
||||
await api.deleteClient(deleting.id)
|
||||
toast.ok('Device deleted')
|
||||
setDeleting(null)
|
||||
load.reload()
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setBusyDelete(false)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Labels are edited in place: click, type, Enter. Escape reverts. */
|
||||
function LabelCell({
|
||||
client,
|
||||
onSave,
|
||||
}: {
|
||||
client: Client
|
||||
onSave: (label: string) => void
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [value, setValue] = useState(client.label ?? '')
|
||||
const ref = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => setValue(client.label ?? ''), [client.label])
|
||||
useEffect(() => {
|
||||
if (editing) ref.current?.select()
|
||||
}, [editing])
|
||||
|
||||
if (!editing) {
|
||||
return (
|
||||
<button
|
||||
className={cx('label-cell__btn', !client.label && 'is-empty')}
|
||||
onClick={() => setEditing(true)}
|
||||
title={client.label || 'Add a label'}
|
||||
>
|
||||
{client.label || 'add label'}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const commit = () => {
|
||||
setEditing(false)
|
||||
const next = value.trim()
|
||||
if (next !== (client.label ?? '')) onSave(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
className="input"
|
||||
style={{ height: 24, fontSize: 'var(--t-sm)' }}
|
||||
value={value}
|
||||
aria-label="Device label"
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
commit()
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
setValue(client.label ?? '')
|
||||
setEditing(false)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import { api } from '../lib/api'
|
||||
import type { AccessState, Client, Source, Stats } from '../lib/types'
|
||||
import { compactNumber, timeAgo, truncMiddle, useAsync } from '../lib/util'
|
||||
import { sourceHue } from '../lib/spectrum'
|
||||
import { PageHeader } from '../components/Layout'
|
||||
import { EmptyState, ErrorState, TableSkeleton } from '../components/States'
|
||||
import { IconRefresh, IconShield, IconAlert } from '../components/Icons'
|
||||
import { href } from '../lib/router'
|
||||
|
||||
interface DashData {
|
||||
stats: Stats
|
||||
access: AccessState
|
||||
sources: Source[]
|
||||
recent: Client[]
|
||||
}
|
||||
|
||||
function Tile({
|
||||
label,
|
||||
value,
|
||||
sub,
|
||||
hue,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
sub?: string
|
||||
hue: string
|
||||
}) {
|
||||
return (
|
||||
<div className="tile" style={{ ['--hue' as string]: hue } as never}>
|
||||
<div className="tile__label">{label}</div>
|
||||
<div className="tile__value">{value}</div>
|
||||
{sub && <div className="tile__sub">{sub}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Dashboard() {
|
||||
const load = useAsync<DashData>(async () => {
|
||||
const [stats, access, sources, recent] = await Promise.all([
|
||||
api.stats(),
|
||||
api.access(),
|
||||
api.listSources(),
|
||||
api.listClients({ limit: 8, offset: 0, sort: 'last_seen' }),
|
||||
])
|
||||
return { stats, access, sources, recent: recent.items }
|
||||
}, [])
|
||||
|
||||
const d = load.data
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
eyebrow="Overview"
|
||||
title="Dashboard"
|
||||
actions={
|
||||
<button className="btn" onClick={load.reload} disabled={load.loading}>
|
||||
{load.loading ? <span className="spinner" /> : <IconRefresh size={14} />}
|
||||
Refresh
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="page">
|
||||
{load.error && !d ? (
|
||||
<ErrorState message={load.error} onRetry={load.reload} />
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid--tiles">
|
||||
<Tile
|
||||
label="Sources"
|
||||
value={d ? String(d.stats.sources) : '—'}
|
||||
sub="upstream subscriptions"
|
||||
hue="var(--sp-5)"
|
||||
/>
|
||||
<Tile
|
||||
label="Groups"
|
||||
value={d ? String(d.stats.groups) : '—'}
|
||||
sub="merged links"
|
||||
hue="var(--sp-6)"
|
||||
/>
|
||||
<Tile
|
||||
label="Devices seen"
|
||||
value={d ? compactNumber(d.stats.clients) : '—'}
|
||||
sub={d ? `${d.stats.banned_clients} banned` : undefined}
|
||||
hue="var(--sp-4)"
|
||||
/>
|
||||
<Tile
|
||||
label="Requests · 24h"
|
||||
value={d ? compactNumber(d.stats.requests_24h) : '—'}
|
||||
sub={d ? `${compactNumber(d.stats.total_requests)} all time` : undefined}
|
||||
hue="var(--sp-3)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={
|
||||
d?.access.whitelist_active ? 'banner banner--locked' : 'banner banner--open'
|
||||
}
|
||||
>
|
||||
<span className="banner__icon">
|
||||
{d?.access.whitelist_active ? <IconShield size={18} /> : <IconAlert size={18} />}
|
||||
</span>
|
||||
<span className="banner__text">
|
||||
<span className="banner__title">
|
||||
{d?.access.whitelist_active ? 'Whitelist active' : 'Open to everyone'}
|
||||
</span>
|
||||
<span className="banner__body">
|
||||
{d?.access.whitelist_active ? (
|
||||
<>
|
||||
Only the {d.access.whitelisted} allowed device
|
||||
{d.access.whitelisted === 1 ? '' : 's'} can fetch a link.
|
||||
{d.access.banned > 0 && ` ${d.access.banned} banned.`}{' '}
|
||||
<a href={href('/clients')}>Manage devices</a>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
The whitelist is empty, so every device that finds a link may use it —
|
||||
except the {d?.access.banned ?? 0} banned one
|
||||
{d?.access.banned === 1 ? '' : 's'}. Allow a device to switch the
|
||||
whitelist on. <a href={href('/clients')}>Manage devices</a>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid--split">
|
||||
<div className="card">
|
||||
<div className="card__head">
|
||||
<div className="card__title">Recent devices</div>
|
||||
<a className="btn btn--sm btn--ghost" href={href('/clients')}>
|
||||
View all
|
||||
</a>
|
||||
</div>
|
||||
<div className="card__body card__body--flush">
|
||||
{load.loading && !d ? (
|
||||
<TableSkeleton rows={5} cols={4} />
|
||||
) : d && d.recent.length > 0 ? (
|
||||
<div className="tbl-wrap">
|
||||
<table className="tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>HWID</th>
|
||||
<th>Client</th>
|
||||
<th className="num">Hits</th>
|
||||
<th>Last seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{d.recent.map((c) => (
|
||||
<tr key={c.id}>
|
||||
<td>
|
||||
<span className="hwid__text" title={c.hwid}>
|
||||
{truncMiddle(c.hwid, 10, 6)}
|
||||
</span>
|
||||
{c.label && (
|
||||
<span className="chip chip--beam" style={{ marginLeft: 8 }}>
|
||||
{c.label}
|
||||
</span>
|
||||
)}
|
||||
{c.banned && (
|
||||
<span className="chip chip--danger" style={{ marginLeft: 8 }}>
|
||||
banned
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="ua-cell" title={c.user_agent}>
|
||||
{c.user_agent || '—'}
|
||||
</td>
|
||||
<td className="num">{compactNumber(c.hits)}</td>
|
||||
<td className="mono muted">{timeAgo(c.last_seen)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="No devices yet"
|
||||
body="Devices appear here the first time they fetch one of your links."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card__head">
|
||||
<div className="card__title">Source health</div>
|
||||
<a className="btn btn--sm btn--ghost" href={href('/sources')}>
|
||||
View all
|
||||
</a>
|
||||
</div>
|
||||
<div className="card__body card__body--flush">
|
||||
{load.loading && !d ? (
|
||||
<TableSkeleton rows={4} cols={2} />
|
||||
) : d && d.sources.length > 0 ? (
|
||||
<div className="health">
|
||||
{d.sources.map((s) => (
|
||||
<div className="health__row" key={s.id}>
|
||||
<span
|
||||
className="node__tick"
|
||||
style={{ ['--hue' as string]: sourceHue(s.id) } as never}
|
||||
/>
|
||||
<span className="health__name" title={s.name}>
|
||||
{s.name}
|
||||
</span>
|
||||
{!s.enabled ? (
|
||||
<span className="chip chip--mute">off</span>
|
||||
) : s.last_fetch_ok ? (
|
||||
<span className="chip chip--ok">{s.last_node_count} nodes</span>
|
||||
) : s.last_fetch_at ? (
|
||||
<span
|
||||
className="chip chip--danger"
|
||||
title={s.last_fetch_error || 'fetch failed'}
|
||||
>
|
||||
failed
|
||||
</span>
|
||||
) : (
|
||||
<span className="chip chip--mute">untested</span>
|
||||
)}
|
||||
<span className="health__meta">{timeAgo(s.last_fetch_at)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="No sources"
|
||||
body="Add the upstream subscription you want to unpin from its device."
|
||||
action={
|
||||
<a className="btn btn--primary" href={href('/sources')}>
|
||||
Add a source
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,707 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { api, FALLBACK_FORMATS } from '../lib/api'
|
||||
import type {
|
||||
Filter,
|
||||
FormatOption,
|
||||
Group,
|
||||
GroupMember,
|
||||
PreviewResult,
|
||||
Source,
|
||||
} from '../lib/types'
|
||||
import { emptyFilter, emptyGroup } from '../lib/types'
|
||||
import { compactNumber, cx, publicLink, timeAgo, useAsync } from '../lib/util'
|
||||
import { sourceHue } from '../lib/spectrum'
|
||||
import { PageHeader } from '../components/Layout'
|
||||
import { Confirm, Drawer } from '../components/Drawer'
|
||||
import { Field, Toggle } from '../components/Form'
|
||||
import { FilterEditor } from '../components/FilterEditor'
|
||||
import { PublicLink } from '../components/PublicLink'
|
||||
import { NodeList } from '../components/NodeList'
|
||||
import { EmptyState, ErrorState, TableSkeleton } from '../components/States'
|
||||
import { useToast } from '../components/Toast'
|
||||
import {
|
||||
IconAlert,
|
||||
IconChevronDown,
|
||||
IconChevronUp,
|
||||
IconEdit,
|
||||
IconPlay,
|
||||
IconPlus,
|
||||
IconRefresh,
|
||||
IconTrash,
|
||||
IconX,
|
||||
} from '../components/Icons'
|
||||
|
||||
interface Loaded {
|
||||
groups: Group[]
|
||||
sources: Source[]
|
||||
formats: FormatOption[]
|
||||
}
|
||||
|
||||
/** Mirrors Filter.IsZero in internal/model: would this filter change anything? */
|
||||
function filterActive(f?: Filter): boolean {
|
||||
if (!f) return false
|
||||
return Boolean(
|
||||
f.include_name ||
|
||||
f.exclude_name ||
|
||||
f.include_content ||
|
||||
f.exclude_content ||
|
||||
(f.protocols?.length ?? 0) > 0 ||
|
||||
(f.limit ?? 0) > 0,
|
||||
)
|
||||
}
|
||||
|
||||
export function Groups() {
|
||||
const load = useAsync<Loaded>(async () => {
|
||||
const [groups, sources] = await Promise.all([api.listGroups(), api.listSources()])
|
||||
let formats: FormatOption[] = []
|
||||
try {
|
||||
formats = await api.formats()
|
||||
} catch {
|
||||
// /api/formats is a convenience; the picker still works without it.
|
||||
}
|
||||
return { groups, sources, formats: formats.length ? formats : FALLBACK_FORMATS }
|
||||
}, [])
|
||||
|
||||
const [editing, setEditing] = useState<Group | null>(null)
|
||||
const [deleting, setDeleting] = useState<Group | null>(null)
|
||||
const [busyDelete, setBusyDelete] = useState(false)
|
||||
const toast = useToast()
|
||||
|
||||
const d = load.data
|
||||
const sourceById = useMemo(() => {
|
||||
const m = new Map<number, Source>()
|
||||
for (const s of d?.sources ?? []) m.set(s.id, s)
|
||||
return m
|
||||
}, [d])
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deleting) return
|
||||
setBusyDelete(true)
|
||||
try {
|
||||
await api.deleteGroup(deleting.id)
|
||||
toast.ok(`Deleted ${deleting.name}`)
|
||||
setDeleting(null)
|
||||
load.reload()
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setBusyDelete(false)
|
||||
}
|
||||
}
|
||||
|
||||
const canCreate = (d?.sources.length ?? 0) > 0
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
eyebrow="Merged links"
|
||||
title="Groups"
|
||||
actions={
|
||||
<>
|
||||
<button className="btn" onClick={load.reload} disabled={load.loading}>
|
||||
{load.loading ? <span className="spinner" /> : <IconRefresh size={14} />}
|
||||
Refresh
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--primary"
|
||||
onClick={() => setEditing(emptyGroup())}
|
||||
disabled={!canCreate}
|
||||
title={canCreate ? undefined : 'Add at least one source first'}
|
||||
>
|
||||
<IconPlus size={14} />
|
||||
Add group
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="page">
|
||||
<div className="card">
|
||||
<div className="card__body card__body--flush">
|
||||
{load.error && !d ? (
|
||||
<ErrorState message={load.error} onRetry={load.reload} />
|
||||
) : load.loading && !d ? (
|
||||
<TableSkeleton rows={4} cols={5} />
|
||||
) : d && d.groups.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No groups yet"
|
||||
body={
|
||||
canCreate
|
||||
? 'A group merges several sources into one link, with regex filtering and an output format of your choice.'
|
||||
: 'Groups merge sources, so add a source first.'
|
||||
}
|
||||
action={
|
||||
canCreate ? (
|
||||
<button className="btn btn--primary" onClick={() => setEditing(emptyGroup())}>
|
||||
<IconPlus size={14} />
|
||||
Add group
|
||||
</button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="tbl-wrap">
|
||||
<table className="tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Group</th>
|
||||
<th>Members</th>
|
||||
<th>Output</th>
|
||||
<th>Public link</th>
|
||||
<th className="num">Hits</th>
|
||||
<th className="actions">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{d?.groups.map((g) => (
|
||||
<tr key={g.id}>
|
||||
<td>
|
||||
<div className="src-name__text">
|
||||
<span className="src-name__title">
|
||||
{g.name}
|
||||
{!g.enabled && (
|
||||
<span className="chip chip--mute" style={{ marginLeft: 8 }}>
|
||||
disabled
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="src-name__url">
|
||||
{g.dedupe ? 'deduped' : 'all nodes'}
|
||||
{g.filter?.limit ? ` · limit ${g.filter.limit}` : ''}
|
||||
{g.name_template ? ` · renamed` : ''}
|
||||
{` · updated ${timeAgo(g.updated_at)}`}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{g.members.length === 0 && <span className="muted">none</span>}
|
||||
{g.members.map((m) => (
|
||||
<span
|
||||
key={m.source_id}
|
||||
className="chip chip--proto"
|
||||
style={
|
||||
{ ['--hue' as string]: sourceHue(m.source_id) } as never
|
||||
}
|
||||
title={m.name || sourceById.get(m.source_id)?.name}
|
||||
>
|
||||
{m.name || sourceById.get(m.source_id)?.name || `#${m.source_id}`}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className="chip">{g.output_format || 'negotiate'}</span>
|
||||
</td>
|
||||
<td className="link-cell">
|
||||
<PublicLink url={publicLink(g.token)} name={g.name} />
|
||||
</td>
|
||||
<td className="num">{compactNumber(g.hits)}</td>
|
||||
<td className="actions">
|
||||
<button
|
||||
className="ibtn"
|
||||
title="Edit"
|
||||
aria-label={`Edit ${g.name}`}
|
||||
onClick={() => setEditing(g)}
|
||||
>
|
||||
<IconEdit size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="ibtn is-danger"
|
||||
title="Delete"
|
||||
aria-label={`Delete ${g.name}`}
|
||||
onClick={() => setDeleting(g)}
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editing && d && (
|
||||
<GroupDrawer
|
||||
group={editing}
|
||||
sources={d.sources}
|
||||
formats={d.formats}
|
||||
onClose={() => setEditing(null)}
|
||||
onSaved={(g) => {
|
||||
setEditing(g)
|
||||
load.reload()
|
||||
}}
|
||||
onDone={() => {
|
||||
setEditing(null)
|
||||
load.reload()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Confirm
|
||||
open={!!deleting}
|
||||
title="Delete this group?"
|
||||
body={
|
||||
<>
|
||||
<strong>{deleting?.name}</strong> and its public link are removed. The member
|
||||
sources are untouched.
|
||||
</>
|
||||
}
|
||||
confirmLabel="Delete group"
|
||||
destructive
|
||||
busy={busyDelete}
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => setDeleting(null)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function GroupDrawer({
|
||||
group,
|
||||
sources,
|
||||
formats,
|
||||
onClose,
|
||||
onSaved,
|
||||
onDone,
|
||||
}: {
|
||||
group: Group
|
||||
sources: Source[]
|
||||
formats: FormatOption[]
|
||||
onClose: () => void
|
||||
onSaved: (g: Group) => void
|
||||
onDone: () => void
|
||||
}) {
|
||||
const [form, setForm] = useState<Group>(group)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [openMember, setOpenMember] = useState<number | null>(null)
|
||||
const [preview, setPreview] = useState<PreviewResult | null>(null)
|
||||
const [previewError, setPreviewError] = useState<string | null>(null)
|
||||
const [previewing, setPreviewing] = useState(false)
|
||||
const toast = useToast()
|
||||
const isNew = form.id === 0
|
||||
|
||||
useEffect(() => setForm(group), [group])
|
||||
|
||||
const set = <K extends keyof Group>(k: K, v: Group[K]) =>
|
||||
setForm((f) => ({ ...f, [k]: v }))
|
||||
|
||||
const byId = useMemo(() => {
|
||||
const m = new Map<number, Source>()
|
||||
for (const s of sources) m.set(s.id, s)
|
||||
return m
|
||||
}, [sources])
|
||||
|
||||
const addMember = (s: Source) => {
|
||||
if (form.members.some((m) => m.source_id === s.id)) return
|
||||
set('members', [
|
||||
...form.members,
|
||||
{ source_id: s.id, name: s.name, prefix: '', filter: emptyFilter() },
|
||||
])
|
||||
}
|
||||
|
||||
const patchMember = (i: number, patch: Partial<GroupMember>) =>
|
||||
set(
|
||||
'members',
|
||||
form.members.map((m, j) => (i === j ? { ...m, ...patch } : m)),
|
||||
)
|
||||
|
||||
const removeMember = (i: number) => {
|
||||
set('members', form.members.filter((_, j) => j !== i))
|
||||
setOpenMember(null)
|
||||
}
|
||||
|
||||
const move = (i: number, delta: number) => {
|
||||
const next = [...form.members]
|
||||
const j = i + delta
|
||||
if (j < 0 || j >= next.length) return
|
||||
const tmp = next[i]
|
||||
next[i] = next[j]
|
||||
next[j] = tmp
|
||||
set('members', next)
|
||||
setOpenMember(openMember === i ? j : openMember === j ? i : openMember)
|
||||
}
|
||||
|
||||
const validate = (): boolean => {
|
||||
const e: Record<string, string> = {}
|
||||
if (!form.name.trim()) e.name = 'Give the group a name.'
|
||||
if (form.members.length === 0) e.members = 'A group needs at least one source.'
|
||||
setErrors(e)
|
||||
return Object.keys(e).length === 0
|
||||
}
|
||||
|
||||
const payload = (): Partial<Group> => ({
|
||||
...form,
|
||||
name: form.name.trim(),
|
||||
name_template: form.name_template.trim(),
|
||||
})
|
||||
|
||||
const save = async (): Promise<Group | null> => {
|
||||
if (busy) return null
|
||||
if (!validate()) return null
|
||||
setBusy(true)
|
||||
try {
|
||||
const saved = isNew
|
||||
? await api.createGroup(payload())
|
||||
: await api.updateGroup(form.id, payload())
|
||||
toast.ok(isNew ? `Added ${form.name}` : `Saved ${form.name}`)
|
||||
const next = saved && typeof saved === 'object' && 'id' in saved ? saved : { ...form }
|
||||
onSaved(next as Group)
|
||||
return next as Group
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : String(e))
|
||||
return null
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const runPreview = async () => {
|
||||
if (previewing) return
|
||||
if (!validate()) return
|
||||
setPreviewing(true)
|
||||
setPreviewError(null)
|
||||
try {
|
||||
// A group must exist before it can be previewed; save the draft first so
|
||||
// the preview always reflects what is on screen.
|
||||
let id = form.id
|
||||
if (isNew) {
|
||||
const saved = await save()
|
||||
if (!saved) return
|
||||
id = saved.id
|
||||
}
|
||||
const res = await api.previewGroup(id, payload())
|
||||
setPreview(res)
|
||||
} catch (e) {
|
||||
setPreview(null)
|
||||
setPreviewError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setPreviewing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const available = sources.filter((s) => !form.members.some((m) => m.source_id === s.id))
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open
|
||||
wide
|
||||
eyebrow={isNew ? 'New group' : 'Edit group'}
|
||||
title={form.name || 'Untitled group'}
|
||||
onClose={onClose}
|
||||
onSubmit={() => void save()}
|
||||
footer={
|
||||
<>
|
||||
<button className="btn" onClick={runPreview} disabled={previewing || busy}>
|
||||
{previewing ? <span className="spinner" /> : <IconPlay size={14} />}
|
||||
Preview
|
||||
</button>
|
||||
<span className="spacer" />
|
||||
<span className="drawer__hintkey">Ctrl+Enter saves · Esc closes</span>
|
||||
<button className="btn" onClick={onClose} disabled={busy}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--primary"
|
||||
onClick={async () => {
|
||||
const saved = await save()
|
||||
if (saved) onDone()
|
||||
}}
|
||||
disabled={busy}
|
||||
>
|
||||
{busy && <span className="spinner" />}
|
||||
{isNew ? 'Add group' : 'Save changes'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="form-grid">
|
||||
<Field label="Name" required error={errors.name}>
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
className={cx('input', errors.name && 'is-invalid')}
|
||||
value={form.name}
|
||||
placeholder="All servers"
|
||||
onChange={(e) => set('name', e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field
|
||||
label="Output format"
|
||||
hint="Negotiate reads the client User-Agent and the ?format= query."
|
||||
>
|
||||
{(id) => (
|
||||
<select
|
||||
id={id}
|
||||
className="select"
|
||||
value={form.output_format}
|
||||
onChange={(e) => set('output_format', e.target.value)}
|
||||
>
|
||||
<option value="">negotiate from the client</option>
|
||||
{formats.map((f) => (
|
||||
<option key={f.value} value={f.value}>
|
||||
{f.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{/* ---------------------------------------------------------- members */}
|
||||
<div>
|
||||
<div className="section-label" style={{ marginBottom: 'var(--s3)' }}>
|
||||
Members · render order
|
||||
</div>
|
||||
{errors.members && (
|
||||
<div className="inline-note inline-note--danger" style={{ marginBottom: 'var(--s3)' }}>
|
||||
<span className="inline-note__icon">
|
||||
<IconAlert size={14} />
|
||||
</span>
|
||||
<span>{errors.members}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{form.members.map((m, i) => {
|
||||
const src = byId.get(m.source_id)
|
||||
const open = openMember === i
|
||||
return (
|
||||
<div className="member" key={`${m.source_id}-${i}`}>
|
||||
<div className="member__head">
|
||||
<span
|
||||
className="member__tick"
|
||||
style={{ ['--hue' as string]: sourceHue(m.source_id) } as never}
|
||||
/>
|
||||
<span className="member__order">
|
||||
<button
|
||||
type="button"
|
||||
className="member__ord-btn"
|
||||
disabled={i === 0}
|
||||
aria-label={`Move ${m.name ?? src?.name} up`}
|
||||
onClick={() => move(i, -1)}
|
||||
>
|
||||
<IconChevronUp size={11} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="member__ord-btn"
|
||||
disabled={i === form.members.length - 1}
|
||||
aria-label={`Move ${m.name ?? src?.name} down`}
|
||||
onClick={() => move(i, 1)}
|
||||
>
|
||||
<IconChevronDown size={11} />
|
||||
</button>
|
||||
</span>
|
||||
<span className="member__name">
|
||||
{m.name || src?.name || `Source #${m.source_id}`}
|
||||
{!src && (
|
||||
<span className="chip chip--warn" style={{ marginLeft: 8 }}>
|
||||
missing
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{m.prefix && <span className="chip">prefix</span>}
|
||||
{filterActive(m.filter) && <span className="chip chip--beam">filtered</span>}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm btn--ghost"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpenMember(open ? null : i)}
|
||||
>
|
||||
{open ? 'Done' : 'Options'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ibtn is-danger"
|
||||
aria-label={`Remove ${m.name ?? src?.name} from the group`}
|
||||
onClick={() => removeMember(i)}
|
||||
>
|
||||
<IconX size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{open && (
|
||||
<div className="member__body">
|
||||
<Field
|
||||
label="Name prefix"
|
||||
hint="Prepended to every node from this source, before the group template."
|
||||
>
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
className="input input--mono"
|
||||
value={m.prefix ?? ''}
|
||||
placeholder="[DE] "
|
||||
onChange={(e) => patchMember(i, { prefix: e.target.value })}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<div className="filter-box">
|
||||
<div className="label">Filter · runs before the group filter</div>
|
||||
<FilterEditor
|
||||
compact
|
||||
value={m.filter ?? emptyFilter()}
|
||||
onChange={(f) => patchMember(i, { filter: f })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{available.length > 0 && (
|
||||
<div style={{ marginTop: 'var(--s3)' }}>
|
||||
<div className="label" style={{ marginBottom: 6 }}>
|
||||
Add a source
|
||||
</div>
|
||||
<div className="picker">
|
||||
{available.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
className="picker__item"
|
||||
onClick={() => addMember(s)}
|
||||
>
|
||||
<span
|
||||
className="node__tick"
|
||||
style={{ ['--hue' as string]: sourceHue(s.id) } as never}
|
||||
/>
|
||||
<span className="member__name">{s.name}</span>
|
||||
{!s.enabled && <span className="chip chip--mute">disabled</span>}
|
||||
<IconPlus size={14} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* --------------------------------------------------- group filtering */}
|
||||
<div>
|
||||
<div className="section-label" style={{ marginBottom: 'var(--s3)' }}>
|
||||
Group filter · applied to the merged list
|
||||
</div>
|
||||
<FilterEditor value={form.filter} onChange={(f) => set('filter', f)} />
|
||||
</div>
|
||||
|
||||
{/* ------------------------------------------------------------ render */}
|
||||
<div>
|
||||
<div className="section-label" style={{ marginBottom: 'var(--s3)' }}>
|
||||
Render
|
||||
</div>
|
||||
<div className="form-grid">
|
||||
<Field
|
||||
label="Name template"
|
||||
className="span-2"
|
||||
hint="Placeholders: {name} {source} {index} {protocol} {server}. Blank keeps the original names."
|
||||
>
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
className="input input--mono"
|
||||
value={form.name_template}
|
||||
spellCheck={false}
|
||||
placeholder="{index}. {name} · {source}"
|
||||
onChange={(e) => set('name_template', e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Toggle
|
||||
checked={form.dedupe}
|
||||
onChange={(v) => set('dedupe', v)}
|
||||
title="Drop duplicates"
|
||||
sub="Same server, port and protocol appears once."
|
||||
/>
|
||||
<Toggle
|
||||
checked={form.enabled}
|
||||
onChange={(v) => set('enabled', v)}
|
||||
title="Enabled"
|
||||
sub={form.enabled ? 'The public link answers.' : 'The public link returns 404.'}
|
||||
/>
|
||||
<Field label="Note" className="span-2" hint="For you only.">
|
||||
{(id) => (
|
||||
<textarea
|
||||
id={id}
|
||||
className="textarea"
|
||||
rows={2}
|
||||
value={form.note ?? ''}
|
||||
onChange={(e) => set('note', e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isNew && (
|
||||
<div>
|
||||
<div className="section-label" style={{ marginBottom: 'var(--s3)' }}>
|
||||
Public link
|
||||
</div>
|
||||
<PublicLink url={publicLink(form.token)} name={form.name || 'this group'} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ----------------------------------------------------------- preview */}
|
||||
<div>
|
||||
<div className="section-label" style={{ marginBottom: 'var(--s3)' }}>
|
||||
Preview
|
||||
</div>
|
||||
{previewError ? (
|
||||
<div className="inline-note inline-note--danger">
|
||||
<span className="inline-note__icon">
|
||||
<IconAlert size={14} />
|
||||
</span>
|
||||
<span>{previewError}</span>
|
||||
</div>
|
||||
) : previewing ? (
|
||||
<div className="inline-note">
|
||||
<span className="spinner" />
|
||||
<span>Fetching every member and merging…</span>
|
||||
</div>
|
||||
) : preview ? (
|
||||
<div className="test-panel">
|
||||
<div className="test-panel__head preview-head">
|
||||
<span className="label">Renders as</span>
|
||||
<span className="chip chip--beam">{preview.format || 'negotiate'}</span>
|
||||
<span className="label" style={{ marginLeft: 'auto' }}>
|
||||
{preview.count} nodes
|
||||
</span>
|
||||
<button className="btn btn--sm" onClick={runPreview}>
|
||||
<IconRefresh size={13} />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
<div className="test-panel__list">
|
||||
{(preview.nodes?.length ?? 0) > 0 ? (
|
||||
<NodeList nodes={preview.nodes ?? []} showSource />
|
||||
) : (
|
||||
<EmptyState
|
||||
title="Nothing survived"
|
||||
body="Every node was filtered out. Loosen the include patterns or the protocol allow-list."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="inline-note">
|
||||
<span className="inline-note__icon">
|
||||
<IconPlay size={14} />
|
||||
</span>
|
||||
<span>
|
||||
Run a preview to fetch every member, apply the filters and see the merged
|
||||
list exactly as a client would receive it.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { api, token } from '../lib/api'
|
||||
import { Field } from '../components/Form'
|
||||
import { IconAlert, PrismMark } from '../components/Icons'
|
||||
|
||||
/** The product statement, drawn once: white light enters the glass and leaves
|
||||
* as a spectrum. One beam in, many out — which is exactly what Prizma does to
|
||||
* a subscription link. */
|
||||
function PrismStage() {
|
||||
// Refraction spreads by wavelength: red bends least, violet most. The fan
|
||||
// below is ordered accordingly rather than evenly, because that is the fact
|
||||
// the whole product name rests on.
|
||||
const rays = [
|
||||
{ d: 'M264 156 L640 78', c: 'var(--sp-0)' },
|
||||
{ d: 'M266 161 L640 104', c: 'var(--sp-1)' },
|
||||
{ d: 'M268 166 L640 132', c: 'var(--sp-2)' },
|
||||
{ d: 'M270 171 L640 163', c: 'var(--sp-3)' },
|
||||
{ d: 'M272 176 L640 197', c: 'var(--sp-4)' },
|
||||
{ d: 'M274 181 L640 234', c: 'var(--sp-5)' },
|
||||
{ d: 'M276 186 L640 274', c: 'var(--sp-6)' },
|
||||
]
|
||||
return (
|
||||
<svg
|
||||
className="login__prism"
|
||||
viewBox="0 0 640 300"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="glass" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stopColor="#2b3247" stopOpacity="0.9" />
|
||||
<stop offset="100%" stopColor="#111420" stopOpacity="0.95" />
|
||||
</linearGradient>
|
||||
<filter id="soft" x="-40%" y="-40%" width="180%" height="180%">
|
||||
<feGaussianBlur stdDeviation="7" />
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{/* one beam in */}
|
||||
<path
|
||||
className="ray"
|
||||
d="M0 156 L246 156"
|
||||
stroke="#dfe4f2"
|
||||
strokeWidth="1.8"
|
||||
opacity="0.9"
|
||||
style={{ animationDelay: '0.05s' }}
|
||||
/>
|
||||
<path d="M0 156 L246 156" stroke="#dfe4f2" strokeWidth="6" opacity="0.1" filter="url(#soft)" />
|
||||
|
||||
{/* the glass */}
|
||||
<g className="glass-face" style={{ animationDelay: '0.3s' }}>
|
||||
<path
|
||||
d="M246 78 L318 218 L174 218 Z"
|
||||
fill="url(#glass)"
|
||||
stroke="#3c4564"
|
||||
strokeWidth="1.1"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
|
||||
{/* many out */}
|
||||
{rays.map((r, i) => (
|
||||
<g key={r.c}>
|
||||
<path
|
||||
className="ray"
|
||||
d={r.d}
|
||||
stroke={r.c}
|
||||
strokeWidth="1.5"
|
||||
opacity="0.95"
|
||||
style={{ animationDelay: `${0.55 + i * 0.07}s` }}
|
||||
/>
|
||||
<path d={r.d} stroke={r.c} strokeWidth="7" opacity="0.09" filter="url(#soft)" />
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function Login({ onAuthed }: { onAuthed: () => void }) {
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const first = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
first.current?.focus()
|
||||
}, [])
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (busy) return
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await api.login(username, password)
|
||||
if (!res?.token) throw new Error('The server did not return a session token.')
|
||||
token.set(res.token)
|
||||
onAuthed()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login">
|
||||
<section className="login__stage">
|
||||
<div className="login__eyebrow">
|
||||
<PrismMark size={20} />
|
||||
Prizma
|
||||
</div>
|
||||
<div className="login__art">
|
||||
<PrismStage />
|
||||
</div>
|
||||
<div className="login__lede">
|
||||
<h1 className="login__headline">
|
||||
One upstream seat.
|
||||
<br />
|
||||
Every device you own.
|
||||
</h1>
|
||||
<p className="login__sub">
|
||||
The upstream panel pins your subscription to a single device. Prizma keeps
|
||||
that HWID, presents it on every fetch, and hands out a link nothing counts.
|
||||
</p>
|
||||
</div>
|
||||
<div className="login__foot">
|
||||
<span>proxy</span>
|
||||
<span>merge</span>
|
||||
<span>filter</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="login__panel">
|
||||
<form className="login__form" onSubmit={submit}>
|
||||
<div>
|
||||
<div className="login__title">Sign in</div>
|
||||
<p className="login__note" style={{ marginTop: 6 }}>
|
||||
Use the admin credentials from the server environment.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Field label="Username">
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
ref={first}
|
||||
className="input"
|
||||
autoComplete="username"
|
||||
autoCapitalize="off"
|
||||
spellCheck={false}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field label="Password">
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
className="input"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
{error && (
|
||||
<div className="inline-note inline-note--danger" role="alert">
|
||||
<span className="inline-note__icon">
|
||||
<IconAlert size={14} />
|
||||
</span>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button className="btn btn--primary btn--lg btn--block" type="submit" disabled={busy}>
|
||||
{busy && <span className="spinner" />}
|
||||
{busy ? 'Signing in' : 'Sign in'}
|
||||
</button>
|
||||
|
||||
<p className="login__note">
|
||||
Sessions are kept in this browser only. Signing out clears the token.
|
||||
</p>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,693 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { api, ApiError } from '../lib/api'
|
||||
import type { Source, TestResult } from '../lib/types'
|
||||
import { emptySource } from '../lib/types'
|
||||
import {
|
||||
compactNumber,
|
||||
cx,
|
||||
newHwid,
|
||||
publicLink,
|
||||
timeAgo,
|
||||
truncMiddle,
|
||||
useAsync,
|
||||
} from '../lib/util'
|
||||
import { sourceHue } from '../lib/spectrum'
|
||||
import { PageHeader } from '../components/Layout'
|
||||
import { Confirm, Drawer, Modal } from '../components/Drawer'
|
||||
import { Field, HeadersEditor, Toggle } from '../components/Form'
|
||||
import { CopyButton, PublicLink } from '../components/PublicLink'
|
||||
import { NodeList } from '../components/NodeList'
|
||||
import { EmptyState, ErrorState, TableSkeleton } from '../components/States'
|
||||
import { useToast } from '../components/Toast'
|
||||
import {
|
||||
IconAlert,
|
||||
IconCheck,
|
||||
IconDice,
|
||||
IconEdit,
|
||||
IconPlay,
|
||||
IconPlus,
|
||||
IconRefresh,
|
||||
IconSearch,
|
||||
IconTrash,
|
||||
} from '../components/Icons'
|
||||
|
||||
/** Client strings the upstream panels actually recognise. Picking one pins the
|
||||
* format the upstream answers with; leaving it blank forwards the real client
|
||||
* UA so negotiation works end to end. */
|
||||
const UA_PRESETS: { label: string; value: string }[] = [
|
||||
{ label: 'Happ', value: 'Happ/2.6.0' },
|
||||
{ label: 'v2rayNG', value: 'v2rayNG/1.9.16' },
|
||||
{ label: 'Clash Verge', value: 'clash-verge/v2.0.3' },
|
||||
{ label: 'sing-box', value: 'sing-box 1.10.0' },
|
||||
{ label: 'Streisand', value: 'Streisand/1.7.2' },
|
||||
]
|
||||
|
||||
export function Sources() {
|
||||
const load = useAsync(() => api.listSources(), [])
|
||||
const [query, setQuery] = useState('')
|
||||
const [editing, setEditing] = useState<Source | null>(null)
|
||||
const [deleting, setDeleting] = useState<Source | null>(null)
|
||||
const [busyDelete, setBusyDelete] = useState(false)
|
||||
const [testing, setTesting] = useState<{ source: Source; result: TestResult | null; error: string | null } | null>(null)
|
||||
const toast = useToast()
|
||||
|
||||
const sources = load.data ?? []
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return sources
|
||||
return sources.filter(
|
||||
(s) =>
|
||||
s.name.toLowerCase().includes(q) ||
|
||||
s.url.toLowerCase().includes(q) ||
|
||||
s.hwid.toLowerCase().includes(q),
|
||||
)
|
||||
}, [sources, query])
|
||||
|
||||
const runTest = async (s: Source) => {
|
||||
setTesting({ source: s, result: null, error: null })
|
||||
try {
|
||||
const res = await api.testSource(s.id)
|
||||
setTesting({ source: s, result: res, error: null })
|
||||
load.reload()
|
||||
} catch (e) {
|
||||
setTesting({
|
||||
source: s,
|
||||
result: null,
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const rotate = async (s: Source) => {
|
||||
try {
|
||||
await api.rotateHwid(s.id)
|
||||
toast.ok(`Rotated the HWID for ${s.name}`)
|
||||
load.reload()
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
}
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deleting) return
|
||||
setBusyDelete(true)
|
||||
try {
|
||||
await api.deleteSource(deleting.id)
|
||||
toast.ok(`Deleted ${deleting.name}`)
|
||||
setDeleting(null)
|
||||
load.reload()
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setBusyDelete(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
eyebrow="Upstream"
|
||||
title="Sources"
|
||||
actions={
|
||||
<>
|
||||
<button className="btn" onClick={load.reload} disabled={load.loading}>
|
||||
{load.loading ? <span className="spinner" /> : <IconRefresh size={14} />}
|
||||
Refresh
|
||||
</button>
|
||||
<button className="btn btn--primary" onClick={() => setEditing(emptySource())}>
|
||||
<IconPlus size={14} />
|
||||
Add source
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="page">
|
||||
<div className="toolbar">
|
||||
<div className="search" style={{ width: 280 }}>
|
||||
<span className="search__icon">
|
||||
<IconSearch size={14} />
|
||||
</span>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Filter by name, URL or HWID"
|
||||
aria-label="Filter sources"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="toolbar__spacer" />
|
||||
<span className="label">
|
||||
{filtered.length} of {sources.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card__body card__body--flush">
|
||||
{load.error && !load.data ? (
|
||||
<ErrorState message={load.error} onRetry={load.reload} />
|
||||
) : load.loading && !load.data ? (
|
||||
<TableSkeleton rows={5} cols={5} />
|
||||
) : filtered.length === 0 ? (
|
||||
sources.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No sources yet"
|
||||
body="A source is one upstream subscription. Prizma holds the HWID it was pinned to and hands you a link with no device limit."
|
||||
action={
|
||||
<button className="btn btn--primary" onClick={() => setEditing(emptySource())}>
|
||||
<IconPlus size={14} />
|
||||
Add source
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="Nothing matches"
|
||||
body={`No source matches "${query}". Clear the filter to see them all.`}
|
||||
action={
|
||||
<button className="btn" onClick={() => setQuery('')}>
|
||||
Clear filter
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="tbl-wrap">
|
||||
<table className="tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Source</th>
|
||||
<th>Upstream HWID</th>
|
||||
<th>Public link</th>
|
||||
<th>Last fetch</th>
|
||||
<th className="num">Hits</th>
|
||||
<th className="actions">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((s) => (
|
||||
<tr key={s.id}>
|
||||
<td>
|
||||
<div className="src-name">
|
||||
<span
|
||||
className="node__tick"
|
||||
style={{ ['--hue' as string]: sourceHue(s.id) } as never}
|
||||
/>
|
||||
<span className="src-name__text">
|
||||
<span className="src-name__title">
|
||||
{s.name}
|
||||
{!s.enabled && (
|
||||
<span className="chip chip--mute" style={{ marginLeft: 8 }}>
|
||||
disabled
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="src-name__url" title={s.url}>
|
||||
{s.url}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="hwid">
|
||||
<span className="hwid__text" title={s.hwid}>
|
||||
{truncMiddle(s.hwid, 8, 6)}
|
||||
</span>
|
||||
<CopyButton value={s.hwid} what="HWID" />
|
||||
</div>
|
||||
</td>
|
||||
<td className="link-cell">
|
||||
<PublicLink url={publicLink(s.token)} name={s.name} />
|
||||
</td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{!s.last_fetch_at ? (
|
||||
<span className="chip chip--mute">untested</span>
|
||||
) : s.last_fetch_ok ? (
|
||||
<span className="chip chip--ok">{s.last_node_count} nodes</span>
|
||||
) : (
|
||||
<span className="chip chip--danger" title={s.last_fetch_error}>
|
||||
failed
|
||||
</span>
|
||||
)}
|
||||
<span className="mono muted" style={{ fontSize: 'var(--t-micro)' }}>
|
||||
{timeAgo(s.last_fetch_at)}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="num">{compactNumber(s.hits)}</td>
|
||||
<td className="actions">
|
||||
<button
|
||||
className="ibtn"
|
||||
title="Test the upstream"
|
||||
aria-label={`Test ${s.name}`}
|
||||
onClick={() => runTest(s)}
|
||||
>
|
||||
<IconPlay size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="ibtn"
|
||||
title="Rotate the upstream HWID"
|
||||
aria-label={`Rotate the HWID for ${s.name}`}
|
||||
onClick={() => rotate(s)}
|
||||
>
|
||||
<IconDice size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="ibtn"
|
||||
title="Edit"
|
||||
aria-label={`Edit ${s.name}`}
|
||||
onClick={() => setEditing(s)}
|
||||
>
|
||||
<IconEdit size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="ibtn is-danger"
|
||||
title="Delete"
|
||||
aria-label={`Delete ${s.name}`}
|
||||
onClick={() => setDeleting(s)}
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editing && (
|
||||
<SourceDrawer
|
||||
source={editing}
|
||||
onClose={() => setEditing(null)}
|
||||
onSaved={() => {
|
||||
setEditing(null)
|
||||
load.reload()
|
||||
}}
|
||||
onTest={runTest}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Confirm
|
||||
open={!!deleting}
|
||||
title="Delete this source?"
|
||||
body={
|
||||
<>
|
||||
<strong>{deleting?.name}</strong> and its public link are removed. Groups that
|
||||
include it lose those nodes. This cannot be undone.
|
||||
</>
|
||||
}
|
||||
confirmLabel="Delete source"
|
||||
destructive
|
||||
busy={busyDelete}
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => setDeleting(null)}
|
||||
/>
|
||||
|
||||
{testing && (
|
||||
<Modal
|
||||
open
|
||||
width={640}
|
||||
title={`Test · ${testing.source.name}`}
|
||||
onClose={() => setTesting(null)}
|
||||
>
|
||||
<TestReport result={testing.result} error={testing.error} />
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function TestReport({
|
||||
result,
|
||||
error,
|
||||
}: {
|
||||
result: TestResult | null
|
||||
error: string | null
|
||||
}) {
|
||||
if (error) {
|
||||
return (
|
||||
<div className="inline-note inline-note--danger">
|
||||
<span className="inline-note__icon">
|
||||
<IconAlert size={14} />
|
||||
</span>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (!result) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span className="spinner" />
|
||||
<span>Fetching the upstream…</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const nodes = result.nodes ?? []
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s3)' }}>
|
||||
<div
|
||||
className={cx(
|
||||
'inline-note',
|
||||
result.ok ? 'inline-note--ok' : 'inline-note--danger',
|
||||
)}
|
||||
>
|
||||
<span className="inline-note__icon">
|
||||
{result.ok ? <IconCheck size={14} /> : <IconAlert size={14} />}
|
||||
</span>
|
||||
<span>
|
||||
{result.ok
|
||||
? `Parsed ${nodes.length} node${nodes.length === 1 ? '' : 's'} as ${result.format || 'unknown'}.`
|
||||
: result.error || 'The upstream could not be parsed.'}
|
||||
</span>
|
||||
</div>
|
||||
{nodes.length > 0 && (
|
||||
<div className="test-panel">
|
||||
<div className="test-panel__head">
|
||||
<span className="label">Detected</span>
|
||||
<span className="chip chip--beam">{result.format || 'unknown'}</span>
|
||||
<span className="label" style={{ marginLeft: 'auto' }}>
|
||||
{nodes.length} nodes
|
||||
</span>
|
||||
</div>
|
||||
<div className="test-panel__list">
|
||||
<NodeList nodes={nodes} max={200} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SourceDrawer({
|
||||
source,
|
||||
onClose,
|
||||
onSaved,
|
||||
onTest,
|
||||
}: {
|
||||
source: Source
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
onTest: (s: Source) => void
|
||||
}) {
|
||||
const [form, setForm] = useState<Source>(source)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const toast = useToast()
|
||||
const isNew = source.id === 0
|
||||
|
||||
useEffect(() => setForm(source), [source])
|
||||
|
||||
const set = <K extends keyof Source>(k: K, v: Source[K]) =>
|
||||
setForm((f) => ({ ...f, [k]: v }))
|
||||
|
||||
const validate = (): boolean => {
|
||||
const e: Record<string, string> = {}
|
||||
if (!form.name.trim()) e.name = 'Give the source a name you will recognise.'
|
||||
if (!form.url.trim()) e.url = 'The upstream subscription URL is required.'
|
||||
else if (!/^https?:\/\//i.test(form.url.trim()))
|
||||
e.url = 'The URL must start with http:// or https://'
|
||||
if (form.hwid && !/^[0-9a-f]{8,64}$/i.test(form.hwid.trim()))
|
||||
e.hwid = 'An HWID is 8–64 hex characters. Generate one if you are unsure.'
|
||||
if (form.cache_ttl < 0) e.cache_ttl = 'Use 0 for the server default.'
|
||||
setErrors(e)
|
||||
return Object.keys(e).length === 0
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
if (busy) return
|
||||
if (!validate()) return
|
||||
setBusy(true)
|
||||
const payload: Partial<Source> = {
|
||||
...form,
|
||||
name: form.name.trim(),
|
||||
url: form.url.trim(),
|
||||
hwid: form.hwid.trim(),
|
||||
user_agent: form.user_agent.trim(),
|
||||
cache_ttl: Number(form.cache_ttl) || 0,
|
||||
}
|
||||
try {
|
||||
if (isNew) {
|
||||
await api.createSource(payload)
|
||||
toast.ok(`Added ${payload.name}`)
|
||||
} else {
|
||||
await api.updateSource(form.id, payload)
|
||||
toast.ok(`Saved ${payload.name}`)
|
||||
}
|
||||
onSaved()
|
||||
} catch (e) {
|
||||
const msg = e instanceof ApiError ? e.message : String(e)
|
||||
toast.error(msg)
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open
|
||||
eyebrow={isNew ? 'New source' : 'Edit source'}
|
||||
title={form.name || 'Untitled source'}
|
||||
onClose={onClose}
|
||||
onSubmit={save}
|
||||
footer={
|
||||
<>
|
||||
{!isNew && (
|
||||
<button className="btn" onClick={() => onTest(form)}>
|
||||
<IconPlay size={14} />
|
||||
Test
|
||||
</button>
|
||||
)}
|
||||
<span className="spacer" />
|
||||
<span className="drawer__hintkey">Ctrl+Enter saves · Esc closes</span>
|
||||
<button className="btn" onClick={onClose} disabled={busy}>
|
||||
Cancel
|
||||
</button>
|
||||
<button className="btn btn--primary" onClick={save} disabled={busy}>
|
||||
{busy && <span className="spinner" />}
|
||||
{isNew ? 'Add source' : 'Save changes'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="form-grid">
|
||||
<Field label="Name" required error={errors.name} className="span-2">
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
className={cx('input', errors.name && 'is-invalid')}
|
||||
value={form.name}
|
||||
placeholder="Remnawave — main"
|
||||
onChange={(e) => set('name', e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Upstream URL"
|
||||
required
|
||||
error={errors.url}
|
||||
className="span-2"
|
||||
hint="The subscription link the upstream panel gave you."
|
||||
>
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
className={cx('input input--mono', errors.url && 'is-invalid')}
|
||||
value={form.url}
|
||||
spellCheck={false}
|
||||
placeholder="https://panel.example.com/sub/abc123"
|
||||
onChange={(e) => set('url', e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="section-label" style={{ marginBottom: 'var(--s3)' }}>
|
||||
Device identity sent upstream
|
||||
</div>
|
||||
<div className="form-grid">
|
||||
<Field
|
||||
label="HWID"
|
||||
error={errors.hwid}
|
||||
className="span-2"
|
||||
hint="The device the upstream pinned this subscription to. Prizma presents it on every fetch; leave blank on a new source to have one generated."
|
||||
>
|
||||
{(id) => (
|
||||
<div className="input-group">
|
||||
<input
|
||||
id={id}
|
||||
className={cx('input input--mono', errors.hwid && 'is-invalid')}
|
||||
value={form.hwid}
|
||||
spellCheck={false}
|
||||
placeholder="generated on save"
|
||||
onChange={(e) => set('hwid', e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() => set('hwid', newHwid())}
|
||||
title="Generate a fresh HWID"
|
||||
>
|
||||
<IconDice size={14} />
|
||||
Generate
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field label="Device OS" hint="x-device-os">
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
className="input input--mono"
|
||||
value={form.device_os}
|
||||
placeholder="ios"
|
||||
onChange={(e) => set('device_os', e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="OS version" hint="x-ver-os">
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
className="input input--mono"
|
||||
value={form.ver_os}
|
||||
placeholder="17.5.1"
|
||||
onChange={(e) => set('ver_os', e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Device model" hint="x-device-model" className="span-2">
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
className="input input--mono"
|
||||
value={form.device_model}
|
||||
placeholder="iPhone14,2"
|
||||
onChange={(e) => set('device_model', e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="section-label" style={{ marginBottom: 'var(--s3)' }}>
|
||||
User-Agent
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s3)' }}>
|
||||
<Field
|
||||
label="Sent upstream"
|
||||
hint="Leave blank to forward whatever the client sent — that is what makes format negotiation work end to end. Setting it pins the format the upstream answers with."
|
||||
>
|
||||
{(id) => (
|
||||
<>
|
||||
<input
|
||||
id={id}
|
||||
className="input input--mono"
|
||||
value={form.user_agent}
|
||||
spellCheck={false}
|
||||
placeholder="forward the client's User-Agent"
|
||||
onChange={(e) => set('user_agent', e.target.value)}
|
||||
/>
|
||||
<div className="ua-presets">
|
||||
{UA_PRESETS.map((p) => (
|
||||
<button
|
||||
key={p.value}
|
||||
type="button"
|
||||
className={cx('pill', form.user_agent === p.value && 'is-on')}
|
||||
onClick={() =>
|
||||
set('user_agent', form.user_agent === p.value ? '' : p.value)
|
||||
}
|
||||
title={p.value}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Toggle
|
||||
checked={form.forward_client_ua}
|
||||
onChange={(v) => set('forward_client_ua', v)}
|
||||
title="Forward the client User-Agent"
|
||||
sub={
|
||||
form.forward_client_ua
|
||||
? 'The client UA goes upstream; the field above is only the fallback.'
|
||||
: 'The client UA is hidden; only the value above is sent.'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="section-label" style={{ marginBottom: 'var(--s3)' }}>
|
||||
Extra headers
|
||||
</div>
|
||||
<HeadersEditor
|
||||
value={form.extra_headers ?? {}}
|
||||
onChange={(v) => set('extra_headers', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="section-label" style={{ marginBottom: 'var(--s3)' }}>
|
||||
Behaviour
|
||||
</div>
|
||||
<div className="form-grid">
|
||||
<Field
|
||||
label="Cache TTL"
|
||||
error={errors.cache_ttl}
|
||||
hint="Seconds. 0 uses the server default."
|
||||
>
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
type="number"
|
||||
min={0}
|
||||
className={cx('input input--mono', errors.cache_ttl && 'is-invalid')}
|
||||
value={form.cache_ttl}
|
||||
onChange={(e) => set('cache_ttl', Number(e.target.value))}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', paddingBottom: 6 }}>
|
||||
<Toggle
|
||||
checked={form.enabled}
|
||||
onChange={(v) => set('enabled', v)}
|
||||
title="Enabled"
|
||||
sub={form.enabled ? 'The public link answers.' : 'The public link returns 404.'}
|
||||
/>
|
||||
</div>
|
||||
<Field label="Note" className="span-2" hint="For you only. Never sent anywhere.">
|
||||
{(id) => (
|
||||
<textarea
|
||||
id={id}
|
||||
className="textarea"
|
||||
rows={2}
|
||||
value={form.note ?? ''}
|
||||
onChange={(e) => set('note', e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isNew && (
|
||||
<div>
|
||||
<div className="section-label" style={{ marginBottom: 'var(--s3)' }}>
|
||||
Public link
|
||||
</div>
|
||||
<PublicLink url={publicLink(form.token)} name={form.name || 'this source'} />
|
||||
</div>
|
||||
)}
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--g-0);
|
||||
color: var(--ink);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--t-body);
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
/* A single very wide, very faint spectral wash at the top of the page: the
|
||||
beam entering the glass. It is the only ambient decoration in the product. */
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0 0 auto 0;
|
||||
height: 340px;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
background: radial-gradient(
|
||||
120% 100% at 18% 0%,
|
||||
rgba(47, 214, 201, 0.07),
|
||||
transparent 60%
|
||||
),
|
||||
radial-gradient(90% 100% at 78% 0%, rgba(157, 107, 255, 0.06), transparent 62%);
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
p,
|
||||
figure {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--beam);
|
||||
text-decoration: none;
|
||||
}
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus);
|
||||
border-radius: var(--r-2);
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--beam-a40);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* Scrollbars: thin, glassy, never a UI element in their own right. */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--g-4) transparent;
|
||||
}
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--g-4);
|
||||
border-radius: 99px;
|
||||
border: 3px solid transparent;
|
||||
background-clip: content-box;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #2e3448;
|
||||
background-clip: content-box;
|
||||
}
|
||||
::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* --- shared text primitives ------------------------------------------- */
|
||||
|
||||
.mono {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--t-sm);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--t-label);
|
||||
letter-spacing: var(--track-label);
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-mute);
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--ink-mute);
|
||||
}
|
||||
.dim {
|
||||
color: var(--ink-dim);
|
||||
}
|
||||
|
||||
.trunc {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.001ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.001ms !important;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,316 @@
|
||||
.shell {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: var(--rail-w) minmax(0, 1fr);
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
Rail — the beam
|
||||
====================================================================== */
|
||||
|
||||
.rail {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
align-self: start;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s5);
|
||||
padding: var(--s5) var(--s3) var(--s3);
|
||||
border-right: 1px solid var(--line);
|
||||
background: linear-gradient(180deg, #0c0e15, var(--g-0));
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s3);
|
||||
padding: 0 var(--s2);
|
||||
min-width: 0;
|
||||
}
|
||||
.brand__mark {
|
||||
flex: none;
|
||||
}
|
||||
.brand__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
.brand__name {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--t-h2);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.brand__tag {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--t-micro);
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--ink-faint);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.nav__item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: 34px;
|
||||
padding: 0 var(--s2) 0 14px;
|
||||
border-radius: var(--r-2);
|
||||
color: var(--ink-mute);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--t-sm);
|
||||
letter-spacing: 0.04em;
|
||||
text-decoration: none;
|
||||
transition: background 0.14s var(--ease), color 0.14s var(--ease);
|
||||
}
|
||||
.nav__item:hover {
|
||||
background: var(--g-2s);
|
||||
color: var(--ink-dim);
|
||||
text-decoration: none;
|
||||
}
|
||||
/* The signature: the white beam runs down the rail and, at the active item,
|
||||
refracts into the full spectrum. One idea, stated once. */
|
||||
.nav__item::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 3px;
|
||||
top: 8px;
|
||||
bottom: 8px;
|
||||
width: 2px;
|
||||
border-radius: 99px;
|
||||
background: transparent;
|
||||
transition: background 0.18s var(--ease), top 0.18s var(--ease),
|
||||
bottom 0.18s var(--ease);
|
||||
}
|
||||
.nav__item.is-active {
|
||||
color: var(--ink);
|
||||
background: var(--g-2s);
|
||||
}
|
||||
.nav__item.is-active::before {
|
||||
top: 5px;
|
||||
bottom: 5px;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
var(--sp-0),
|
||||
var(--sp-2),
|
||||
var(--sp-3),
|
||||
var(--sp-4),
|
||||
var(--sp-6)
|
||||
);
|
||||
}
|
||||
.nav__icon {
|
||||
display: flex;
|
||||
flex: none;
|
||||
color: inherit;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.nav__count {
|
||||
margin-left: auto;
|
||||
font-size: var(--t-micro);
|
||||
color: var(--ink-faint);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.rail__foot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s2);
|
||||
padding-top: var(--s3);
|
||||
border-top: 1px solid var(--line-soft);
|
||||
}
|
||||
.rail__user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s2);
|
||||
padding: 0 var(--s2);
|
||||
min-width: 0;
|
||||
}
|
||||
.rail__who {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--t-micro);
|
||||
color: var(--ink-mute);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
Main column
|
||||
====================================================================== */
|
||||
|
||||
.main {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s4);
|
||||
padding: var(--s4) var(--s5);
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: rgba(10, 11, 17, 0.86);
|
||||
backdrop-filter: blur(10px);
|
||||
min-height: 62px;
|
||||
}
|
||||
.topbar__title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
min-width: 0;
|
||||
}
|
||||
.topbar__eyebrow {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--t-label);
|
||||
letter-spacing: var(--track-label);
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
.topbar__h1 {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--t-h1);
|
||||
font-weight: 600;
|
||||
letter-spacing: var(--track-tight);
|
||||
}
|
||||
.topbar__actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s2);
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.page {
|
||||
padding: var(--s5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s5);
|
||||
max-width: var(--page-max);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.toolbar__spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: var(--s4);
|
||||
}
|
||||
.grid--2 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.grid--tiles {
|
||||
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
|
||||
}
|
||||
.grid--split {
|
||||
grid-template-columns: minmax(0, 1.45fr) minmax(0, 1fr);
|
||||
align-items: start;
|
||||
}
|
||||
@media (max-width: 1180px) {
|
||||
.grid--split,
|
||||
.grid--2 {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* Compact rail below a wide laptop; the panel stays usable at 1024px. */
|
||||
@media (max-width: 1080px) {
|
||||
:root {
|
||||
--rail-w: 60px;
|
||||
}
|
||||
.rail {
|
||||
padding-left: var(--s2);
|
||||
padding-right: var(--s2);
|
||||
align-items: stretch;
|
||||
}
|
||||
.brand__text,
|
||||
.nav__label,
|
||||
.nav__count,
|
||||
.rail__who {
|
||||
display: none;
|
||||
}
|
||||
.brand {
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
.nav__item {
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
.nav__item::before {
|
||||
left: 0;
|
||||
}
|
||||
.page,
|
||||
.topbar {
|
||||
padding-left: var(--s4);
|
||||
padding-right: var(--s4);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.shell {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.rail {
|
||||
position: static;
|
||||
height: auto;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--s3);
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: var(--s2) var(--s3);
|
||||
overflow-x: auto;
|
||||
}
|
||||
.nav {
|
||||
flex-direction: row;
|
||||
flex: 1;
|
||||
}
|
||||
.nav__item::before {
|
||||
left: 6px;
|
||||
right: 6px;
|
||||
top: auto;
|
||||
bottom: 2px;
|
||||
width: auto;
|
||||
height: 2px;
|
||||
}
|
||||
.nav__item.is-active::before {
|
||||
top: auto;
|
||||
bottom: 2px;
|
||||
background: var(--spectrum);
|
||||
}
|
||||
.rail__foot {
|
||||
border-top: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
/* ======================================================================
|
||||
Login — the one orchestrated moment: a beam enters the glass and splits
|
||||
====================================================================== */
|
||||
|
||||
.login {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
min-height: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 420px;
|
||||
}
|
||||
|
||||
.login__stage {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-right: 1px solid var(--line);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s5);
|
||||
padding: var(--s6) var(--s6) var(--s5);
|
||||
background: radial-gradient(70% 60% at 45% 40%, #10131d, var(--g-0) 72%);
|
||||
}
|
||||
/* The art gets its own band so nothing ever crosses the copy. */
|
||||
.login__art {
|
||||
flex: 1;
|
||||
min-height: 180px;
|
||||
position: relative;
|
||||
}
|
||||
.login__prism {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.login__lede {
|
||||
position: relative;
|
||||
max-width: 54ch;
|
||||
}
|
||||
.login__eyebrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--t-label);
|
||||
letter-spacing: var(--track-label);
|
||||
text-transform: uppercase;
|
||||
color: var(--beam);
|
||||
}
|
||||
.login__headline {
|
||||
font-family: var(--font-mono);
|
||||
font-size: clamp(22px, 2.6vw, 34px);
|
||||
font-weight: 700;
|
||||
line-height: 1.18;
|
||||
letter-spacing: -0.035em;
|
||||
max-width: 23ch;
|
||||
}
|
||||
.login__sub {
|
||||
margin-top: var(--s3);
|
||||
color: var(--ink-mute);
|
||||
font-size: var(--t-body);
|
||||
line-height: 1.65;
|
||||
max-width: 52ch;
|
||||
}
|
||||
.login__foot {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: var(--s5);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--t-micro);
|
||||
color: var(--ink-faint);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.login__panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: var(--s6) var(--s6);
|
||||
background: var(--g-1);
|
||||
}
|
||||
.login__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s4);
|
||||
}
|
||||
.login__title {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--t-h1);
|
||||
font-weight: 600;
|
||||
letter-spacing: var(--track-tight);
|
||||
}
|
||||
.login__note {
|
||||
font-size: var(--t-micro);
|
||||
color: var(--ink-faint);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.login {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.login__stage {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: var(--s6) var(--s4);
|
||||
min-height: 300px;
|
||||
}
|
||||
.login__panel {
|
||||
padding: var(--s5) var(--s4) var(--s7);
|
||||
}
|
||||
}
|
||||
|
||||
/* beam draw-in, once, on load */
|
||||
.ray {
|
||||
stroke-dasharray: 400;
|
||||
stroke-dashoffset: 400;
|
||||
animation: ray-draw 0.9s var(--ease) forwards;
|
||||
}
|
||||
@keyframes ray-draw {
|
||||
to {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
.glass-face {
|
||||
animation: glass-in 0.7s var(--ease) both;
|
||||
}
|
||||
@keyframes glass-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
Dashboard
|
||||
====================================================================== */
|
||||
|
||||
.tile {
|
||||
position: relative;
|
||||
padding: var(--s4);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-3);
|
||||
background: var(--g-1);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.tile::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
height: 2px;
|
||||
background: var(--hue, var(--line));
|
||||
opacity: 0.85;
|
||||
}
|
||||
.tile__label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--t-label);
|
||||
letter-spacing: var(--track-label);
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-mute);
|
||||
}
|
||||
.tile__value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--t-display);
|
||||
font-weight: 600;
|
||||
line-height: 1.05;
|
||||
letter-spacing: var(--track-tight);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.tile__sub {
|
||||
font-size: var(--t-micro);
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.health {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.health__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s3);
|
||||
padding: 9px var(--s4);
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
min-width: 0;
|
||||
}
|
||||
.health__row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.health__name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: var(--t-sm);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.health__meta {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--t-micro);
|
||||
color: var(--ink-faint);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
Sources
|
||||
====================================================================== */
|
||||
|
||||
.src-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s3);
|
||||
min-width: 0;
|
||||
}
|
||||
.src-name__text {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
.src-name__title {
|
||||
font-size: var(--t-body);
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.src-name__url {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--t-micro);
|
||||
color: var(--ink-faint);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 42ch;
|
||||
}
|
||||
.link-cell {
|
||||
position: relative;
|
||||
min-width: 220px;
|
||||
max-width: 340px;
|
||||
}
|
||||
|
||||
.ua-presets {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.test-panel {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-2);
|
||||
overflow: hidden;
|
||||
background: var(--g-2s);
|
||||
}
|
||||
.test-panel__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s2);
|
||||
padding: 8px var(--s3);
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
}
|
||||
.test-panel__list {
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
Groups
|
||||
====================================================================== */
|
||||
|
||||
.member {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-2);
|
||||
background: var(--g-2s);
|
||||
overflow: hidden;
|
||||
}
|
||||
.member__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s2);
|
||||
padding: 7px var(--s2) 7px var(--s3);
|
||||
min-width: 0;
|
||||
}
|
||||
.member__tick {
|
||||
width: 3px;
|
||||
height: 18px;
|
||||
border-radius: 99px;
|
||||
background: var(--hue, var(--ink-faint));
|
||||
flex: none;
|
||||
}
|
||||
.member__name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: var(--t-sm);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.member__order {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
padding: 2px 1px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-2);
|
||||
background: var(--g-1);
|
||||
}
|
||||
.member__ord-btn {
|
||||
width: 22px;
|
||||
height: 14px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--ink-mute);
|
||||
cursor: pointer;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.member__ord-btn:hover:not(:disabled) {
|
||||
color: var(--ink);
|
||||
background: var(--g-3);
|
||||
}
|
||||
.member__ord-btn:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.member__body {
|
||||
padding: var(--s3);
|
||||
border-top: 1px solid var(--line-soft);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s3);
|
||||
}
|
||||
|
||||
.picker {
|
||||
max-height: 210px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-2);
|
||||
background: var(--g-2s);
|
||||
}
|
||||
.picker__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s3);
|
||||
width: 100%;
|
||||
padding: 7px var(--s3);
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
background: transparent;
|
||||
color: var(--ink-dim);
|
||||
font: inherit;
|
||||
font-size: var(--t-sm);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.picker__item:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.picker__item:hover:not(:disabled) {
|
||||
background: var(--g-3);
|
||||
color: var(--ink);
|
||||
}
|
||||
.picker__item:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.filter-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s3);
|
||||
padding: var(--s3);
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: var(--r-2);
|
||||
background: var(--g-2s);
|
||||
}
|
||||
|
||||
.preview-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
Clients
|
||||
====================================================================== */
|
||||
|
||||
.banner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--s3);
|
||||
padding: var(--s3) var(--s4);
|
||||
border-radius: var(--r-3);
|
||||
border: 1px solid var(--line);
|
||||
background: var(--g-1);
|
||||
}
|
||||
.banner--open {
|
||||
border-color: rgba(255, 209, 102, 0.28);
|
||||
background: var(--warn-a12);
|
||||
}
|
||||
.banner--locked {
|
||||
border-color: var(--beam-a40);
|
||||
background: var(--beam-a12);
|
||||
}
|
||||
.banner__icon {
|
||||
flex: none;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.banner--open .banner__icon {
|
||||
color: var(--warn);
|
||||
}
|
||||
.banner--locked .banner__icon {
|
||||
color: var(--beam);
|
||||
}
|
||||
.banner__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
.banner__title {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--t-sm);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.banner__body {
|
||||
font-size: var(--t-sm);
|
||||
color: var(--ink-dim);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.hwid {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.hwid__text {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--t-micro);
|
||||
color: var(--ink-dim);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.label-cell {
|
||||
min-width: 90px;
|
||||
max-width: 180px;
|
||||
}
|
||||
.label-cell__btn {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: 1px dashed transparent;
|
||||
background: transparent;
|
||||
color: var(--ink-dim);
|
||||
border-radius: var(--r-1);
|
||||
padding: 2px 5px;
|
||||
font: inherit;
|
||||
font-size: var(--t-sm);
|
||||
cursor: text;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.label-cell__btn:hover {
|
||||
border-color: var(--line);
|
||||
color: var(--ink);
|
||||
}
|
||||
.label-cell__btn.is-empty {
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.ua-cell {
|
||||
max-width: 260px;
|
||||
font-size: var(--t-micro);
|
||||
color: var(--ink-mute);
|
||||
font-family: var(--font-mono);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/* Prizma design tokens.
|
||||
*
|
||||
* The whole system comes out of one idea: a prism takes a single beam and
|
||||
* splits it into an ordered spectrum. Prizma takes a single upstream link and
|
||||
* splits it into many. So the palette is literally a spectrum ramp, ordered by
|
||||
* wavelength, and every semantic colour is a stop on that ramp — nothing is
|
||||
* invented outside it. Sources and protocols are tinted by an index into the
|
||||
* same ramp, which makes the identity carry information instead of decorating.
|
||||
*
|
||||
* Surfaces are optical glass: cool, slightly violet charcoal rather than black,
|
||||
* so the spectrum reads as light passing through rather than paint on paper.
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* --- surfaces: optical glass ------------------------------------------ */
|
||||
--g-0: #0a0b11; /* page */
|
||||
--g-1: #0f1118; /* panel */
|
||||
--g-2: #14172140; /* raised, translucent over panel */
|
||||
--g-2s: #141721; /* raised, solid */
|
||||
--g-3: #1a1e2b; /* hover / input */
|
||||
--g-4: #222739; /* active / pressed */
|
||||
--line: #232838;
|
||||
--line-soft: #191d29;
|
||||
--scrim: rgba(6, 7, 11, 0.72);
|
||||
|
||||
/* --- ink --------------------------------------------------------------- */
|
||||
--ink: #e9ecf7;
|
||||
--ink-dim: #a8afc6;
|
||||
--ink-mute: #6f7794;
|
||||
--ink-faint: #4b5269;
|
||||
|
||||
/* --- the spectrum, in wavelength order --------------------------------- */
|
||||
--sp-0: #ff5f56; /* red */
|
||||
--sp-1: #ff9b3d; /* orange */
|
||||
--sp-2: #ffd166; /* yellow */
|
||||
--sp-3: #5fd97a; /* green */
|
||||
--sp-4: #2fd6c9; /* cyan */
|
||||
--sp-5: #4f9dff; /* blue */
|
||||
--sp-6: #9d6bff; /* violet */
|
||||
|
||||
/* semantics are stops on the ramp, never new hues */
|
||||
--beam: var(--sp-4);
|
||||
--beam-ink: #04191c;
|
||||
--beam-dim: #1f8f89;
|
||||
--beam-a12: rgba(47, 214, 201, 0.12);
|
||||
--beam-a22: rgba(47, 214, 201, 0.22);
|
||||
--beam-a40: rgba(47, 214, 201, 0.4);
|
||||
--ok: var(--sp-3);
|
||||
--ok-a12: rgba(95, 217, 122, 0.12);
|
||||
--warn: var(--sp-2);
|
||||
--warn-a12: rgba(255, 209, 102, 0.12);
|
||||
--danger: var(--sp-0);
|
||||
--danger-a12: rgba(255, 95, 86, 0.12);
|
||||
--danger-a22: rgba(255, 95, 86, 0.22);
|
||||
--info: var(--sp-5);
|
||||
--info-a12: rgba(79, 157, 255, 0.12);
|
||||
|
||||
--spectrum: linear-gradient(
|
||||
90deg,
|
||||
var(--sp-0),
|
||||
var(--sp-1),
|
||||
var(--sp-2),
|
||||
var(--sp-3),
|
||||
var(--sp-4),
|
||||
var(--sp-5),
|
||||
var(--sp-6)
|
||||
);
|
||||
|
||||
/* --- type -------------------------------------------------------------- */
|
||||
/* Monospace is the identity face, not a code affordance: everything this
|
||||
panel handles is machine text — hex HWIDs, tokens, URLs, regexes. It is
|
||||
used for headings, labels and data; the sans face carries prose only. */
|
||||
--font-mono: ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Mono",
|
||||
"Cascadia Code", Menlo, Consolas, "Liberation Mono", monospace;
|
||||
--font-ui: system-ui, -apple-system, "Segoe UI Variable Text", "Segoe UI",
|
||||
Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
|
||||
--t-display: 26px;
|
||||
--t-h1: 19px;
|
||||
--t-h2: 15px;
|
||||
--t-body: 13.5px;
|
||||
--t-sm: 12.5px;
|
||||
--t-label: 11px;
|
||||
--t-micro: 10.5px;
|
||||
|
||||
--track-label: 0.14em;
|
||||
--track-tight: -0.015em;
|
||||
|
||||
/* --- space (4px base) --------------------------------------------------- */
|
||||
--s1: 4px;
|
||||
--s2: 8px;
|
||||
--s3: 12px;
|
||||
--s4: 16px;
|
||||
--s5: 24px;
|
||||
--s6: 32px;
|
||||
--s7: 48px;
|
||||
|
||||
/* --- shape & depth ------------------------------------------------------ */
|
||||
--r-1: 3px;
|
||||
--r-2: 6px;
|
||||
--r-3: 10px;
|
||||
--shadow-1: 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||
--shadow-2: 0 10px 30px -12px rgba(0, 0, 0, 0.75);
|
||||
--shadow-3: -24px 0 60px -20px rgba(0, 0, 0, 0.8);
|
||||
--focus: 0 0 0 2px var(--g-0), 0 0 0 4px var(--beam-a40);
|
||||
|
||||
--rail-w: 236px;
|
||||
--page-max: 1560px;
|
||||
|
||||
--ease: cubic-bezier(0.2, 0.7, 0.3, 1);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2021",
|
||||
"lib": ["ES2021", "DOM", "DOM.Iterable"],
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"esModuleInterop": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
"exactOptionalPropertyTypes": false,
|
||||
|
||||
"types": ["vite/client"],
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"esModuleInterop": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"types": ["node"],
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// base './' keeps every asset reference relative, so the bundle embedded into
|
||||
// the Go binary works no matter which path the panel is mounted under.
|
||||
export default defineConfig({
|
||||
base: './',
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': { target: 'http://localhost:8080', changeOrigin: true },
|
||||
'/sub': { target: 'http://localhost:8080', changeOrigin: true },
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
sourcemap: false,
|
||||
// The panel is served from a router or a small VPS; a couple of files beats
|
||||
// a hundred round trips on a slow link.
|
||||
chunkSizeWarningLimit: 900,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user