Initial implementation: proxy checker panel + subscription system
Full stack per GOAL.md / TODO.md:
- checker (Go): fetch sources, canonicalize/dedup (whitelist+sort, & fixes),
validity check (connect+latency+speed gates), geoip by exit IP, session model
("only working / last completed session"), global lock, manual run/stop,
Telegram start/finish notifications. Reuses tessero-checker dialers
(vless/vmess/trojan/ss), upstream parser, geoip, fetcher.
- api (Go): admin auth (single operator), dashboard stats, proxies
list/filter/export/bulk, subscriptions CRUD, sources CRUD, settings, checker
control, and dynamic public /sub endpoints with unique_ips + plain/base64/
clash/singbox rendering.
- frontend (React+Vite+TS+Tailwind): telemetry-console SPA — Dashboard, Proxies,
Subscriptions, Settings.
- postgres schema, docker-compose (postgres/checker/api/web-nginx), Dockerfiles,
Gitea Actions CI.
Verified end-to-end via Docker: pipeline, all sub formats, unique_ips collapse,
and all four panel tabs (Playwright).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TtR4PP2JM9KadaBkhPGAE
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
# Copy to .env and adjust. `docker compose up -d --build` reads this file.
|
||||
|
||||
# --- PostgreSQL ---
|
||||
POSTGRES_USER=perchik
|
||||
POSTGRES_PASSWORD=change-me-db
|
||||
POSTGRES_DB=perchik
|
||||
|
||||
# --- Admin panel (single operator) ---
|
||||
ADMIN_USER=admin
|
||||
ADMIN_PASSWORD=change-me-admin
|
||||
# HMAC key that signs session tokens — use a long random string.
|
||||
SESSION_SECRET=change-me-to-a-long-random-secret
|
||||
|
||||
# --- Runtime ---
|
||||
LOG_LEVEL=info
|
||||
# Host port the panel is served on (http://localhost:8088 by default).
|
||||
WEB_PORT=8088
|
||||
|
||||
# --- Image tagging (used by CI / registry pushes) ---
|
||||
# REGISTRY=git.qomar.pw/omar
|
||||
# TAG=latest
|
||||
@@ -0,0 +1,67 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
# Fast validation on every push/PR: Go vet + tests, and the frontend build
|
||||
# (which runs tsc typecheck first).
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.26"
|
||||
cache: true
|
||||
- name: go vet
|
||||
run: go vet ./...
|
||||
- name: go test
|
||||
run: go test ./...
|
||||
- name: go build
|
||||
run: go build ./...
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: frontend build
|
||||
working-directory: frontend
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
# Build and push the three images to the Gitea container registry. Runs only
|
||||
# on pushes to main. Requires a runner with Docker available.
|
||||
images:
|
||||
needs: validate
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
REGISTRY: git.qomar.pw
|
||||
OWNER: omar
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to Gitea registry
|
||||
run: echo "${{ secrets.GITEA_TOKEN }}" | docker login "$REGISTRY" -u "${{ github.actor }}" --password-stdin
|
||||
|
||||
- name: Build & push checker
|
||||
run: |
|
||||
docker build -f deploy/Dockerfile.checker -t "$REGISTRY/$OWNER/perchik-checker:latest" -t "$REGISTRY/$OWNER/perchik-checker:${GITHUB_SHA::8}" .
|
||||
docker push "$REGISTRY/$OWNER/perchik-checker:latest"
|
||||
docker push "$REGISTRY/$OWNER/perchik-checker:${GITHUB_SHA::8}"
|
||||
|
||||
- name: Build & push api
|
||||
run: |
|
||||
docker build -f deploy/Dockerfile.api -t "$REGISTRY/$OWNER/perchik-api:latest" -t "$REGISTRY/$OWNER/perchik-api:${GITHUB_SHA::8}" .
|
||||
docker push "$REGISTRY/$OWNER/perchik-api:latest"
|
||||
docker push "$REGISTRY/$OWNER/perchik-api:${GITHUB_SHA::8}"
|
||||
|
||||
- name: Build & push web
|
||||
run: |
|
||||
docker build -f deploy/Dockerfile.web -t "$REGISTRY/$OWNER/perchik-web:latest" -t "$REGISTRY/$OWNER/perchik-web:${GITHUB_SHA::8}" .
|
||||
docker push "$REGISTRY/$OWNER/perchik-web:latest"
|
||||
docker push "$REGISTRY/$OWNER/perchik-web:${GITHUB_SHA::8}"
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
# Handoff file contains a secret git token — keep out of the repo
|
||||
GOAL.md
|
||||
|
||||
# Secrets
|
||||
.env
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
frontend/dist/
|
||||
frontend/node_modules/
|
||||
|
||||
# Go
|
||||
/bin/
|
||||
|
||||
# OS / editor
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Build artifacts
|
||||
*.exe
|
||||
api
|
||||
checker
|
||||
frontend/*.tsbuildinfo
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
# Architecture — zhguchiy_perchik
|
||||
|
||||
Proxy checker panel with its own subscription system. Continuously pulls proxy
|
||||
configs (vless/vmess/trojan/ss) from source lists, checks each for validity by
|
||||
its exit IP, stores **only working** ones, and serves them to clients as
|
||||
subscriptions.
|
||||
|
||||
## Services (docker-compose)
|
||||
|
||||
| Service | Stack | Role |
|
||||
|------------|------------------------------------|------|
|
||||
| `postgres` | PostgreSQL 16 | Database |
|
||||
| `checker` | Go (`cmd/checker`) | Worker: fetch sources → canonicalize/dedup → check (connect+latency+speed) → geo by exit IP → write sessions |
|
||||
| `api` | Go (`cmd/api`) | REST API for panel + public subscription endpoints |
|
||||
| `frontend` | React + Vite + TS + Tailwind + shadcn/ui + recharts | SPA panel, built to static |
|
||||
| `edge` | nginx | Serves frontend static, routes `/api` and `/sub` to `api` |
|
||||
|
||||
Single Go module (`git.qomar.pw/omar/zhguchiy_perchik`) with two binaries
|
||||
sharing `internal/` packages (dialers, upstream parser + canonicalization,
|
||||
geoip, fetcher, db, subs). Dialers, geoip, fetcher and the upstream parser are
|
||||
reused from `tessero-checker`; the session model, DB layer, canonicalization,
|
||||
subscription conversion and API are new.
|
||||
|
||||
## Core data model — "only working / last completed session"
|
||||
|
||||
- The panel and subscriptions **always** serve the results of the last
|
||||
**completed** check session. A running/crashed session never affects output.
|
||||
- One session (`check_sessions`) marked `is_current = true` is what everything
|
||||
reads. When a new session completes, a single transaction flips `is_current`
|
||||
to the new one (`WHERE is_current` partial-unique index guarantees exactly one).
|
||||
- `session_proxies` holds the working proxies **of a session** with their metrics
|
||||
at that session (denormalized: canonical_url, protocol, source, host, port,
|
||||
latency, speed, country, exit_ip) so subscription serving is a single-table
|
||||
filtered scan — no cache, evaluated per request.
|
||||
- `proxies` is the persistent per-canonical-URL history (first_seen, last_alive,
|
||||
consecutive_failures, total_checks/passes for uptime) kept **even while a proxy
|
||||
is currently invalid**. Never shown/served directly; only working ones from the
|
||||
current session are.
|
||||
- Source attribution: first-seen source is pinned on the `proxies` row.
|
||||
- Session history is kept forever (trends). Per-session aggregate counters live in
|
||||
`session_protocol_stats` / `session_source_stats` (compact — we do NOT store
|
||||
every failed candidate).
|
||||
- No auto-delete after N failures, no dead pool in output.
|
||||
|
||||
## Validity criterion (all gates)
|
||||
|
||||
1. Successful protocol handshake + exit IP obtained within timeout.
|
||||
2. Latency ≤ threshold (setting).
|
||||
3. Speed ≥ threshold (setting) — speedtest is a validity gate, run for every
|
||||
candidate that passes connect+latency (when speedtest enabled).
|
||||
|
||||
## Uniqueness
|
||||
|
||||
**Canonical URL** (import-time, `internal/upstream/canonical.go`):
|
||||
unescape `&`→`&` (and `;`/double `amp;` variants) → drop `#fragment` →
|
||||
per-protocol query whitelist → sort query alphabetically → normalize form
|
||||
(scheme case, trailing `/`, percent-encoding; vmess re-packed as canonical JSON)
|
||||
→ dedup by canonical URL. Broken/unparseable lines rejected.
|
||||
|
||||
**unique_ips** (display/serve-time, opt-in): collapse proxies sharing one
|
||||
`exit_ip` to a single winner, chosen by metric `speed` (max Mbps, default) or
|
||||
`latency` (min ms); tie-break: lower latency. Does not mutate the DB. Order in
|
||||
subscriptions: filters → unique_ips collapse → sort → limit top-N.
|
||||
|
||||
## Query whitelist (per protocol)
|
||||
|
||||
- **vless**: type, security, encryption, sni, fp, path, host, pbk, sid, flow,
|
||||
headerType, alpn, mode, spx, serviceName, allowInsecure, insecure,
|
||||
packetEncoding, authority, extra, ech, quicSecurity, x_padding_bytes, pcs, pqv,
|
||||
fm, ed, eh
|
||||
- **trojan**: sni, type, security, path, host, allowInsecure, fp, alpn,
|
||||
headerType, mode, serviceName, sid, pbk, spx, peer
|
||||
- **ss**: userinfo = base64(method:password); query: plugin
|
||||
- **vmess**: base64(json), keep add, port, id, aid, net, type, host, path, tls,
|
||||
sni, alpn, fp, scy, v; drop ps and junk (name, test_name, nation, pcs, vcn,
|
||||
deviceID)
|
||||
|
||||
## API (all under `/api/v1`; admin routes require session cookie / bearer)
|
||||
|
||||
Auth: single admin (login+password from env), issues a signed session token.
|
||||
|
||||
- `POST /auth/login`, `POST /auth/logout`, `GET /auth/me`
|
||||
- `GET /dashboard` — full stats bundle (counts, trends, protocol/country/source
|
||||
breakdown, latency/speed distribution, dynamics, uptime, last check, live progress)
|
||||
- `GET /proxies` — filter (protocol, country, source, latency, speed, search),
|
||||
optional `unique_ips` + `metric`; pagination
|
||||
- `GET /proxies/export.txt` — filtered list, `\n`-joined
|
||||
- `POST /proxies/recheck` — recheck selected (ids) — enqueues manual op
|
||||
- `DELETE /proxies` — delete selected (removes from current session view)
|
||||
- `GET/POST/PATCH/DELETE /subscriptions` — CRUD
|
||||
- `GET/POST/PATCH/DELETE /sources` — CRUD (+ enable/disable)
|
||||
- `GET/PATCH /settings`
|
||||
- `POST /checker/run` (manual full cycle), `POST /checker/stop`, `GET /checker/status`
|
||||
- Public: `GET /sub/{token}` — dynamic subscription, honors format & filters,
|
||||
404 when disabled/expired; increments request stats.
|
||||
|
||||
## Settings (keys in `settings` table, typed)
|
||||
|
||||
check_interval_hours(12), workers(10), timeout_sec(5), max_latency_ms(1000),
|
||||
min_speed_mbps(3), speedtest_enabled(true), speedtest_url, geoip_db_url
|
||||
(default geolite2-geo-whois-asn-country mmdb), auto_enabled, telegram_bot_token,
|
||||
telegram_chat_id, telegram_notify_start, telegram_notify_finish.
|
||||
Exit-IP echo URL is hardcoded with fallbacks (not a setting).
|
||||
|
||||
## Deploy
|
||||
|
||||
`docker-compose up`. Schema is embedded and applied idempotently by the checker
|
||||
on boot. `.env` supplies DB creds, admin login/password, session secret.
|
||||
@@ -0,0 +1,70 @@
|
||||
# zhguchiy_perchik
|
||||
|
||||
Proxy checker panel with its own subscription system. It continuously pulls
|
||||
proxy configs (vless / vmess / trojan / ss) from source lists, checks each for
|
||||
validity by its exit IP, keeps **only working** ones, and serves them to clients
|
||||
as subscriptions (plain / base64 / Clash / sing-box).
|
||||
|
||||
See [ARCHITECTURE.md](./ARCHITECTURE.md) for the design and [TODO.md](./TODO.md)
|
||||
for the full feature wishlist.
|
||||
|
||||
## Stack
|
||||
|
||||
| Service | Stack | Role |
|
||||
|------------|-----------------------------------------|------|
|
||||
| `postgres` | PostgreSQL 16 | Database |
|
||||
| `checker` | Go (`cmd/checker`) | Worker: fetch → canonicalize/dedup → check → geo → write sessions |
|
||||
| `api` | Go (`cmd/api`) | REST panel API + public subscription endpoints |
|
||||
| `web` | React + Vite + TS + Tailwind + nginx | SPA panel (Dashboard / Proxies / Subscriptions / Settings), also routes `/api` + `/sub` |
|
||||
|
||||
The vless/vmess/trojan/ss dialers, upstream parser, geoip and fetcher are reused
|
||||
from `tessero-checker`; the canonicalization, session model, DB layer,
|
||||
subscription conversion and API/UI are new.
|
||||
|
||||
## Quick start
|
||||
|
||||
```sh
|
||||
cp .env.example .env
|
||||
# edit .env: set ADMIN_PASSWORD and SESSION_SECRET (long random)
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Open http://localhost:8088 and sign in with `ADMIN_USER` / `ADMIN_PASSWORD`.
|
||||
|
||||
Then in the panel:
|
||||
1. **Settings → Sources**: add one or more list URLs (see TODO.md for examples).
|
||||
2. **Run check** (top bar) or wait for the scheduled interval.
|
||||
3. Working proxies appear in **Proxies**; build client feeds in **Subscriptions**.
|
||||
|
||||
## Model — "only working / last completed session"
|
||||
|
||||
The panel and subscriptions always serve the last **completed** check session; a
|
||||
running or crashed session never affects output. When a session completes, one
|
||||
transaction flips the `is_current` pointer. Per-canonical history (uptime,
|
||||
consecutive failures) is kept even while a proxy is currently invalid, but only
|
||||
working proxies from the current session are ever shown or served.
|
||||
|
||||
## Development
|
||||
|
||||
Backend:
|
||||
|
||||
```sh
|
||||
go test ./...
|
||||
go vet ./...
|
||||
go run ./cmd/api # needs DB_DSN, ADMIN_PASSWORD, SESSION_SECRET
|
||||
go run ./cmd/checker # needs DB_DSN
|
||||
```
|
||||
|
||||
Frontend (proxies `/api` + `/sub` to `localhost:8080`):
|
||||
|
||||
```sh
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## CI
|
||||
|
||||
`.gitea/workflows/ci.yml` runs `go vet` / `go test` / `go build` and the frontend
|
||||
build on every push, then (on `main`) builds and pushes the three images to the
|
||||
Gitea container registry.
|
||||
@@ -0,0 +1,212 @@
|
||||
# zhguchiy_perchik — список желаний (wishlist)
|
||||
|
||||
> Это НЕ архитектура и НЕ ТЗ. Это список того, что панель должна уметь, — «хотелки»
|
||||
> по функционалу. Стек, схему БД, API и деплой обсуждаем отдельно позже.
|
||||
>
|
||||
> Статус: `[~]` — под вопросом (обсуждаем), `[x]` — согласовано, делаем.
|
||||
|
||||
---
|
||||
|
||||
## О проекте
|
||||
|
||||
Панель-чекер прокси со своей sub-системой.
|
||||
|
||||
Суть: постоянно тянем конфиги прокси (vless / vmess / trojan / ss) из списков-источников,
|
||||
проверяем каждый на валидность (по его исходящему IP), храним **только рабочие**,
|
||||
и отдаём клиентам как подписки (sub-ссылки, конфиги через `\n` для Happ / v2rayN и подобных).
|
||||
|
||||
**Критерий «валидности» (что проходит чек):**
|
||||
- [x] Успешный коннект (handshake по протоколу) + удалось узнать exit IP в пределах таймаута.
|
||||
- [x] Латенси не хуже порога (порог в настройках).
|
||||
- [x] Скорость не хуже порога (порог в настройках) — спидтест ЯВЛЯЕТСЯ гейтом валидности,
|
||||
гоняется для каждого кандидата, прошедшего коннект+латенси.
|
||||
|
||||
---
|
||||
|
||||
## Модель данных «только рабочие / только последняя сессия» (ключевой принцип)
|
||||
|
||||
- [x] В базе (для отображения и выдачи) — **только рабочие прокси, и только они**.
|
||||
- [x] Работаем сессиями чека: `check_session_N` отчекала всё → по ней показываются прокси
|
||||
и вся статистика. Пока `check_session_(N+1)` в работе (или крашнулась — неважно),
|
||||
панель и подписки **всегда** показывают результат последней **завершённой** сессии.
|
||||
- [x] Незавершённая / упавшая сессия не должна портить текущую выдачу — переключение на
|
||||
её результат происходит только когда она успешно завершилась.
|
||||
- [x] Нет автоудаления по N провалам и нет «мёртвого пула» в выдаче — невалид просто не
|
||||
попадает в актуальную сессию и не отдаётся клиентам. (см. «Автоудаление — нет».)
|
||||
- [x] Счётчики валид/невалид для статистики берём из результатов сессий (сколько кандидатов
|
||||
проверено, сколько прошло/не прошло), а не из выдаваемого списка.
|
||||
- [x] Персистентная история по canonical URL: на каждый уникальный прокси помним
|
||||
first_seen, last_alive, аптайм, провалы подряд, последние метрики — **даже когда он
|
||||
сейчас невалиден**. Это внутренняя история; в панели/подписках показываем и отдаём
|
||||
только рабочие из последней завершённой сессии. (нужно для аптайма и «провалов подряд»)
|
||||
- [x] Историю сессий храним ВСЮ, без чистки/ретеншена (для трендов по дням/неделям/месяцам).
|
||||
- [x] Атрибуция source: закрепляем за прокси ПЕРВЫЙ увиденный источник (first-seen),
|
||||
один source на прокси.
|
||||
|
||||
---
|
||||
|
||||
## Уникализация прокси (грамотная) — с фактами из реальных источников
|
||||
|
||||
Проверил живьём ~577k строк из источников (`__init__.py`). Что реально прилетает и как чистим:
|
||||
|
||||
**Что нашли (реальная «грязь»):**
|
||||
- Почти у КАЖДОЙ строки есть `#`-метка — это реклама/лейбл, не часть конфига. Примеры:
|
||||
`🔥Join+Telegram:@Farah_VPN🟣` (×6939), `EPODONIOS`, `@oneclickvpnkeys::US`,
|
||||
`@meliproxyy`, `کانال تلگرام`, флаги-эмодзи стран, `t.me/…`.
|
||||
- Рекламные query-параметры: `Telegram` (×25908 у vless), `note` (×1887), `brand`,
|
||||
`@NebulaVPNx`, и даже целый URL как имя ключа: `https://t.me/WangCai2🇨🇳`.
|
||||
- Баг HTML-экранирования: ~3.5% строк содержат `&` вместо `&` → параметры
|
||||
превращаются в битые `amp;type`, `amp;security` (`amp;type` ×15251 у vless), встречается
|
||||
и вариант с ведущим `;` (`;type`) и двойной `amp;;security`.
|
||||
- Битые строки: два конфига слиты без переноса (`…type=tcptrojan://…`), недекодируемые
|
||||
base64-блобы, текст ошибок вида `订阅内容解析错误` («ошибка парсинга подписки»).
|
||||
|
||||
**Правила чистки (желания):**
|
||||
- [x] Разэкранировать `&` → `&` (и разобрать варианты с `;`/двойным `amp;`) ДО парсинга.
|
||||
- [x] Выкинуть фрагмент `#…` целиком (метки/реклама) — до сохранения и до чека.
|
||||
- [x] Вайтлист query-параметров по протоколу: оставляем только реально влияющие на коннект,
|
||||
остальное режем (реклама отваливается сама). Черновой вайтлист:
|
||||
- vless: `type, security, encryption, sni, fp, path, host, pbk, sid, flow, headerType,
|
||||
alpn, mode, spx, serviceName, allowInsecure/insecure, packetEncoding, authority,
|
||||
extra, ech, quicSecurity`; новые xhttp: `x_padding_bytes, pcs, pqv, fm`;
|
||||
ws early-data: `ed, eh`.
|
||||
- trojan: `sni, type, security, path, host, allowInsecure, fp, alpn, headerType, mode,
|
||||
serviceName, sid, pbk, spx, peer`.
|
||||
- ss: userinfo = base64(`method:password`); из query по сути только `plugin`.
|
||||
- vmess: это base64(json) — декодируем, оставляем `add, port, id, aid, net, type, host,
|
||||
path, tls, sni, alpn, fp, scy, v`; выкидываем `ps` (лейбл) и мусор
|
||||
(`name, test_name, nation, pcs, vcn, deviceID`).
|
||||
- Точный вайтлист финализируем при реализации (сверяемся с v2ray URL-спекой).
|
||||
- [x] Сортировать query-параметры по алфавиту → одинаковый прокси в разном порядке
|
||||
параметров даёт один и тот же канонический URL.
|
||||
- [x] Нормализация формы: единый регистр схемы, обрезка хвостового `/` в host:port,
|
||||
нормализация percent-encoding; для vmess — переупаковать json канонично.
|
||||
- [x] Дедуп по каноническому URL (после всей чистки). В базе/выдаче — уже чистый URL.
|
||||
- [x] Битые/непарсящиеся строки — отклонять (не считать за прокси).
|
||||
- [x] Показывать в панели, сколько дублей/мусора схлопнулось при импорте (сырых строк →
|
||||
уникальных после канонизации).
|
||||
|
||||
**Известное ограничение:**
|
||||
- [~] hysteria2 / hy2 / ssr / tuic встречаются (hysteria2 ×594 и т.д.), но Go-диалеры
|
||||
их не умеют — будут отсеиваться. Возможно добавим поддержку позже.
|
||||
|
||||
---
|
||||
|
||||
## Уникализация по out_ip (кнопка `unique_ips`)
|
||||
|
||||
Две РАЗНЫЕ прокси (разный протокол/настройки/конфиг-строка) могут после подключения
|
||||
выходить в интернет с ОДНОГО и того же out_ip — это дубли «по точке выхода» (по конфигу
|
||||
они уникальны, по выходу — нет). Это отдельная от canonical-URL уникализация.
|
||||
|
||||
- [x] Кнопка/тумблер `unique_ips` в табах **Proxies** и **Subscriptions**. Когда включена —
|
||||
группа прокси с одинаковым out_ip схлопывается до ОДНОГО прокси-победителя. На выходе —
|
||||
список / сабка, уникальные по out_ip.
|
||||
- [x] Рядом с кнопкой — переключатель МЕТРИКИ схлопки: по `speed_test` (берём с макс. Mbps)
|
||||
или по `latency` (берём с мин. мс). Победитель на каждый out_ip выбирается по ней.
|
||||
- [x] Дефолт: метрика `speed_test`; тай-брейк при равной скорости — меньшая латенси.
|
||||
Сам `unique_ips` по умолчанию выключен (opt-in).
|
||||
- [x] Это фильтр отображения/выдачи — базу не меняет. В Proxies — тумблер над таблицей;
|
||||
в Subscriptions — опция подписки (хранится в её настройках, влияет на sub-ссылку).
|
||||
|
||||
---
|
||||
|
||||
## Вкладки панели
|
||||
|
||||
### 1. Dashboard (статистика по сети — максимально подробно)
|
||||
|
||||
- [x] Кол-во валид / невалид (по последней сессии), числом и в %.
|
||||
- [x] Разбивка валид/невалид по дням / неделям / месяцам (графики-тренды).
|
||||
- [x] Общее кол-во рабочих прокси в базе (актуальная сессия).
|
||||
- [x] Динамика: сколько добавилось нового / сколько отвалилось за день/неделю/месяц.
|
||||
- [x] Разбивка по протоколам (vless / vmess / trojan / ss) — кол-во и % валида.
|
||||
- [x] Разбивка по странам (гео по exit IP) — топ стран.
|
||||
- [x] Разбивка по источникам — какой source даёт валид, какой мусор.
|
||||
- [x] Статистика по латенси (средняя / медиана / распределение по бакетам мс).
|
||||
- [x] Статистика по скорости (спидтест включаем, ссылка в настройках).
|
||||
- [x] Аптайм конкретных прокси (как долго прокси держится живым между сессиями).
|
||||
- [x] Время последнего чека, длительность.
|
||||
- [x] Живой прогресс текущего цикла (проверено X из Y) + статус активной сессии.
|
||||
|
||||
### 2. Proxies
|
||||
|
||||
- [x] Список прокси с фильтрами: протокол, страна, источник, латенси, скорость.
|
||||
- [x] Поиск по прокси.
|
||||
- [x] Выгрузка отфильтрованного списка в `.txt` (по `\n`).
|
||||
- [x] Скопировать конкретную проксю одной кнопкой.
|
||||
- [x] Массовые действия: удалить выбранные, пере-проверить выбранные.
|
||||
- [x] Метаданные прокси: пинг, страна, exit IP, кол-во провалов подряд, откуда взят.
|
||||
- [x] Кнопка `unique_ips` + переключатель метрики (speed_test / latency) — схлопка списка
|
||||
до уникальных по out_ip (см. раздел «Уникализация по out_ip»).
|
||||
|
||||
### 3. Subscriptions (подписки для клиентов)
|
||||
|
||||
- [x] Создание подписки → уникальная sub-ссылка.
|
||||
- [x] Отдача прокси через `\n` (формат для Happ / v2ray и прочих).
|
||||
- [x] Sub отдаётся ДИНАМИЧЕСКИ: каждый запрос — актуальные рабочие по фильтрам из
|
||||
последней завершённой сессии (без кэша/снапшота).
|
||||
- [x] Фильтры подписки:
|
||||
- [x] по протоколу(ам);
|
||||
- [x] по стране(ам);
|
||||
- [x] по статусу (только живые);
|
||||
- [x] по латенси / скорости (не хуже порога);
|
||||
- [x] по источнику;
|
||||
- [x] лимит количества (топ-N).
|
||||
- [x] Опция `unique_ips` + метрика (speed_test / latency) — сабка отдаёт уникальные по
|
||||
out_ip (см. раздел «Уникализация по out_ip»).
|
||||
- [x] Сортировка выдачи по одному или нескольким параметрам (выбираемым) — напр. по
|
||||
скорости, латенси, стране.
|
||||
- [x] Порядок формирования выдачи подписки: фильтры → (опц.) схлопка `unique_ips` →
|
||||
сортировка по выбранным параметрам → лимит топ-N. Т.е. лимит N считает УЖЕ уникальные
|
||||
по out_ip прокси.
|
||||
- [x] Форматы выдачи (выбираются на подписке): plain text (`\n`), base64 (v2ray/Happ),
|
||||
Clash / Clash.Meta YAML, sing-box JSON.
|
||||
- [x] Вкл/выкл подписки, срок действия. Если подписка выключена или истёк срок — sub-ссылка
|
||||
отвечает 404 / ошибкой.
|
||||
- [x] Статистика по подписке: сколько раз дёргали, когда последний раз.
|
||||
|
||||
### 4. Settings
|
||||
|
||||
- [x] Интервал проверки прокси (как часто гоняем цикл; каждый цикл сам перефетчивает
|
||||
источники — отдельного интервала импорта НЕ надо).
|
||||
- [x] Список source'ов — откуда брать прокси (добавить / удалить / вкл-выкл). Это ссылки
|
||||
на листы (txt / подписки).
|
||||
- [x] Параметры чека: таймаут, кол-во воркеров, порог латенси, порог мин. скорости,
|
||||
интервал чека, вкл/выкл спидтест. Черновые дефолты (тюнятся в UI): интервал 12 ч,
|
||||
воркеры 10, таймаут 5 сек, порог латенси 1000 мс, мин. скорость 3 Mbps.
|
||||
- [x] Ссылка для спидтеста (для замера скорости) — настраивается.
|
||||
- [x] URL для проверки коннекта / получения exit IP — НЕ в настройках, зашит дефолтом
|
||||
(надёжный IP-echo сервис, желательно с фолбэком на несколько).
|
||||
- [x] Гео-база: выбирается в настройках; дефолт — `geolite2-geo-whois-asn-country` (mmdb)
|
||||
из https://github.com/sapics/ip-location-db. Страна прокси определяется по его exit IP.
|
||||
- [x] Ручной запуск чека / импорта кнопкой + кнопка СТОП. Активна может быть только ОДНА
|
||||
операция чека зараз (глобальный лок): полный цикл и ручной «пере-проверить выбранные»
|
||||
не идут параллельно — второй блокируется, пока первый не завершится.
|
||||
- [x] Telegram-уведомления: в настройках задаётся ОДИН bot token + ОДИН chat_id, с
|
||||
чекбоксами когда слать — на НАЧАЛО чека и на КОНЕЦ (в конце — со статистикой сессии).
|
||||
|
||||
---
|
||||
|
||||
## Явные «нет» (чтобы не делать лишнего)
|
||||
|
||||
- [x] Отдельный интервал импорта — НЕ надо (каждый перечек перефетчит источники).
|
||||
- [x] Автоудаление прокси после N провалов — НЕ надо (храним только рабочие, см. модель выше).
|
||||
- [x] Экспорт всей базы / бэкап — НЕ надо.
|
||||
- [x] Уведомления «валида стало меньше обычного» — НЕ надо (телега шлёт только старт/финиш чека).
|
||||
|
||||
---
|
||||
|
||||
## Идеи на потом (не для первой версии)
|
||||
|
||||
- [x] История циклов проверки (когда, сколько проверено, сколько прошло) — нужна.
|
||||
- [~] Поддержка hysteria2 / tuic / ssr — НЕ в первой версии (в источниках ~0.1%), возможно позже.
|
||||
|
||||
---
|
||||
|
||||
## Что осознанно обсуждаем ОТДЕЛЬНО (не сейчас)
|
||||
|
||||
- Стек (бэкенд / чекер / фронт), язык, библиотеки.
|
||||
- Схема БД, API-эндпоинты.
|
||||
- Как именно реализуем модель «последняя завершённая сессия» на уровне БД.
|
||||
- Точный финальный вайтлист параметров по протоколам.
|
||||
- Аутентификация в панель.
|
||||
- Деплой.
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
// Command api serves the admin REST panel and the public subscription
|
||||
// endpoints. It reads only — the checker owns writes and the schema.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.qomar.pw/omar/zhguchiy_perchik/internal/api"
|
||||
"git.qomar.pw/omar/zhguchiy_perchik/internal/db"
|
||||
)
|
||||
|
||||
func main() {
|
||||
log := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: parseLevel(envOr("LOG_LEVEL", "info")),
|
||||
}))
|
||||
|
||||
dsn := os.Getenv("DB_DSN")
|
||||
if dsn == "" {
|
||||
log.Error("DB_DSN is required")
|
||||
os.Exit(1)
|
||||
}
|
||||
adminUser := envOr("ADMIN_USER", "admin")
|
||||
adminPass := os.Getenv("ADMIN_PASSWORD")
|
||||
secret := os.Getenv("SESSION_SECRET")
|
||||
if adminPass == "" || secret == "" {
|
||||
log.Error("ADMIN_PASSWORD and SESSION_SECRET are required")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
database, err := connectWithRetry(ctx, log, dsn)
|
||||
if err != nil {
|
||||
log.Error("db connect failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer database.Close()
|
||||
log.Info("connected to database")
|
||||
|
||||
srv := api.NewServer(api.Config{
|
||||
DB: database,
|
||||
Log: log,
|
||||
AdminUser: adminUser,
|
||||
AdminPassword: adminPass,
|
||||
SessionSecret: secret,
|
||||
AllowOrigin: os.Getenv("CORS_ORIGIN"),
|
||||
})
|
||||
|
||||
addr := envOr("LISTEN_ADDR", ":8080")
|
||||
httpSrv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: srv.Handler(),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
_ = httpSrv.Shutdown(shutdownCtx)
|
||||
}()
|
||||
|
||||
log.Info("api listening", "addr", addr)
|
||||
if err := httpSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Error("server error", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func connectWithRetry(ctx context.Context, log *slog.Logger, dsn string) (*db.DB, error) {
|
||||
delays := []time.Duration{0, 2 * time.Second, 5 * time.Second, 10 * time.Second, 15 * time.Second, 15 * time.Second}
|
||||
var lastErr error
|
||||
for i, d := range delays {
|
||||
if d > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(d):
|
||||
}
|
||||
}
|
||||
database, err := db.New(ctx, dsn)
|
||||
if err == nil {
|
||||
return database, nil
|
||||
}
|
||||
lastErr = err
|
||||
log.Warn("db connect retry", "attempt", i+1, "err", err.Error())
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func envOr(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func parseLevel(s string) slog.Level {
|
||||
switch s {
|
||||
case "debug":
|
||||
return slog.LevelDebug
|
||||
case "warn":
|
||||
return slog.LevelWarn
|
||||
case "error":
|
||||
return slog.LevelError
|
||||
default:
|
||||
return slog.LevelInfo
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// Command checker is the proxy-checking worker: it fetches sources,
|
||||
// canonicalizes/dedups them, validates each candidate, and writes check
|
||||
// sessions to PostgreSQL. It applies the schema on boot.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.qomar.pw/omar/zhguchiy_perchik/internal/checker"
|
||||
"git.qomar.pw/omar/zhguchiy_perchik/internal/config"
|
||||
"git.qomar.pw/omar/zhguchiy_perchik/internal/db"
|
||||
"git.qomar.pw/omar/zhguchiy_perchik/internal/geoip"
|
||||
"git.qomar.pw/omar/zhguchiy_perchik/internal/worker"
|
||||
)
|
||||
|
||||
func main() {
|
||||
log := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: parseLevel(envOr("LOG_LEVEL", "info")),
|
||||
}))
|
||||
|
||||
dsn := os.Getenv("DB_DSN")
|
||||
if dsn == "" {
|
||||
log.Error("DB_DSN is required")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
database, err := connectWithRetry(ctx, log, dsn)
|
||||
if err != nil {
|
||||
log.Error("db connect failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer database.Close()
|
||||
log.Info("connected to database")
|
||||
|
||||
if err := database.ApplySchema(ctx); err != nil {
|
||||
log.Error("schema apply failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
log.Info("schema applied")
|
||||
|
||||
// GeoIP URL comes from settings (falls back to the documented default).
|
||||
settings := config.Default()
|
||||
if m, err := database.GetSettings(ctx); err == nil {
|
||||
settings = config.FromMap(m)
|
||||
}
|
||||
geo, err := geoip.New(ctx, settings.GeoIPDBURL,
|
||||
geoip.WithUpdateInterval(24*time.Hour),
|
||||
geoip.WithLogger(log),
|
||||
)
|
||||
if err != nil {
|
||||
log.Error("geoip init failed", "url", settings.GeoIPDBURL, "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer geo.Close()
|
||||
log.Info("geoip ready", "loaded_at", geo.LastUpdated())
|
||||
|
||||
chk := checker.New(geo, log)
|
||||
w := worker.New(database, chk, geo, log)
|
||||
w.Run(ctx)
|
||||
}
|
||||
|
||||
func connectWithRetry(ctx context.Context, log *slog.Logger, dsn string) (*db.DB, error) {
|
||||
delays := []time.Duration{0, 2 * time.Second, 5 * time.Second, 10 * time.Second, 15 * time.Second, 15 * time.Second}
|
||||
var lastErr error
|
||||
for i, d := range delays {
|
||||
if d > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(d):
|
||||
}
|
||||
}
|
||||
database, err := db.New(ctx, dsn)
|
||||
if err == nil {
|
||||
return database, nil
|
||||
}
|
||||
lastErr = err
|
||||
log.Warn("db connect retry", "attempt", i+1, "err", err.Error())
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func envOr(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func parseLevel(s string) slog.Level {
|
||||
switch s {
|
||||
case "debug":
|
||||
return slog.LevelDebug
|
||||
case "warn":
|
||||
return slog.LevelWarn
|
||||
case "error":
|
||||
return slog.LevelError
|
||||
default:
|
||||
return slog.LevelInfo
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
# API image. Build context is the repo root.
|
||||
FROM golang:1.26 AS build
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/api ./cmd/api
|
||||
|
||||
FROM gcr.io/distroless/static-debian12:nonroot
|
||||
COPY --from=build /out/api /api
|
||||
USER nonroot:nonroot
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/api"]
|
||||
@@ -0,0 +1,12 @@
|
||||
# Checker worker image. Build context is the repo root.
|
||||
FROM golang:1.26 AS build
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/checker ./cmd/checker
|
||||
|
||||
FROM gcr.io/distroless/static-debian12:nonroot
|
||||
COPY --from=build /out/checker /checker
|
||||
USER nonroot:nonroot
|
||||
ENTRYPOINT ["/checker"]
|
||||
@@ -0,0 +1,13 @@
|
||||
# Frontend (nginx) image: builds the SPA to static and serves it, also routing
|
||||
# /api and /sub to the api service. Build context is the repo root.
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
@@ -0,0 +1,41 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# gzip for text assets.
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
|
||||
gzip_min_length 1024;
|
||||
|
||||
# API + public subscription endpoints proxy to the Go api service.
|
||||
location /api/ {
|
||||
proxy_pass http://api:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
location /sub/ {
|
||||
proxy_pass http://api:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
location = /healthz {
|
||||
proxy_pass http://api:8080;
|
||||
}
|
||||
|
||||
# Static assets get long cache; hashed filenames make this safe.
|
||||
location /assets/ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# SPA fallback — every other path serves index.html.
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
# zhguchiy_perchik — full stack in one compose file.
|
||||
# Copy .env.example to .env and adjust before `docker compose up -d --build`.
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-perchik}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-perchik}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-perchik}
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-perchik} -d ${POSTGRES_DB:-perchik}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
checker:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: deploy/Dockerfile.checker
|
||||
image: ${REGISTRY:-zhguchiy_perchik}/checker:${TAG:-latest}
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
DB_DSN: postgres://${POSTGRES_USER:-perchik}:${POSTGRES_PASSWORD:-perchik}@postgres:5432/${POSTGRES_DB:-perchik}?sslmode=disable
|
||||
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: deploy/Dockerfile.api
|
||||
image: ${REGISTRY:-zhguchiy_perchik}/api:${TAG:-latest}
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
DB_DSN: postgres://${POSTGRES_USER:-perchik}:${POSTGRES_PASSWORD:-perchik}@postgres:5432/${POSTGRES_DB:-perchik}?sslmode=disable
|
||||
ADMIN_USER: ${ADMIN_USER:-admin}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?ADMIN_PASSWORD is required}
|
||||
SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET is required}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
web:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: deploy/Dockerfile.web
|
||||
image: ${REGISTRY:-zhguchiy_perchik}/web:${TAG:-latest}
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${WEB_PORT:-8088}:80"
|
||||
depends_on:
|
||||
- api
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="ru" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<title>zhguchiy_perchik — proxy console</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Space+Grotesk:wght@500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+3142
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "zhguchiy-perchik-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc -b --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.62.0",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.468.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"recharts": "^2.15.0",
|
||||
"tailwind-merge": "^2.5.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.11"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api, ApiError } from "@/lib/api";
|
||||
import { Layout } from "./components/Layout";
|
||||
import { Spinner } from "./components/ui";
|
||||
import Login from "./pages/Login";
|
||||
import Dashboard from "./pages/Dashboard";
|
||||
import Proxies from "./pages/Proxies";
|
||||
import Subscriptions from "./pages/Subscriptions";
|
||||
import Settings from "./pages/Settings";
|
||||
|
||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
const me = useQuery({
|
||||
queryKey: ["me"],
|
||||
queryFn: api.me,
|
||||
retry: false,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
if (me.isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (me.isError) {
|
||||
const unauth = me.error instanceof ApiError && me.error.status === 401;
|
||||
return <Navigate to="/login" replace state={{ unauth }} />;
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route
|
||||
element={
|
||||
<RequireAuth>
|
||||
<Layout />
|
||||
</RequireAuth>
|
||||
}
|
||||
>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/proxies" element={<Proxies />} />
|
||||
<Route path="/subscriptions" element={<Subscriptions />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { NavLink, Outlet, useNavigate } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { LayoutDashboard, Radio, Rss, Settings2, Play, Square, LogOut } from "lucide-react";
|
||||
import { api } from "@/lib/api";
|
||||
import { cn } from "@/lib/format";
|
||||
import { Button } from "./ui";
|
||||
import { StatusPill } from "./telemetry";
|
||||
import { useToast } from "./Toast";
|
||||
|
||||
const nav = [
|
||||
{ to: "/", label: "Dashboard", icon: LayoutDashboard, end: true },
|
||||
{ to: "/proxies", label: "Proxies", icon: Radio, end: false },
|
||||
{ to: "/subscriptions", label: "Subscriptions", icon: Rss, end: false },
|
||||
{ to: "/settings", label: "Settings", icon: Settings2, end: false },
|
||||
];
|
||||
|
||||
export function Layout() {
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const status = useQuery({
|
||||
queryKey: ["checker-status"],
|
||||
queryFn: api.checkerStatus,
|
||||
refetchInterval: 3000,
|
||||
});
|
||||
|
||||
const run = useMutation({
|
||||
mutationFn: api.checkerRun,
|
||||
onSuccess: () => {
|
||||
toast("success", "Check queued");
|
||||
qc.invalidateQueries({ queryKey: ["checker-status"] });
|
||||
},
|
||||
onError: (e: Error) => toast("error", e.message),
|
||||
});
|
||||
const stop = useMutation({
|
||||
mutationFn: api.checkerStop,
|
||||
onSuccess: () => {
|
||||
toast("info", "Stopping check…");
|
||||
qc.invalidateQueries({ queryKey: ["checker-status"] });
|
||||
},
|
||||
onError: (e: Error) => toast("error", e.message),
|
||||
});
|
||||
const logout = useMutation({
|
||||
mutationFn: api.logout,
|
||||
onSuccess: () => {
|
||||
qc.clear();
|
||||
navigate("/login");
|
||||
},
|
||||
});
|
||||
|
||||
const busy = status.data?.busy ?? false;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
{/* Nav rail */}
|
||||
<aside className="sticky top-0 flex h-screen w-56 shrink-0 flex-col border-r border-ink-700 bg-ink-900/60 px-3 py-5">
|
||||
<div className="mb-8 flex items-center gap-2.5 px-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-signal/15 text-signal">
|
||||
<Radio size={18} />
|
||||
</div>
|
||||
<div className="leading-tight">
|
||||
<div className="font-display text-sm font-semibold text-fog">zhguchiy</div>
|
||||
<div className="font-mono text-[10px] uppercase tracking-widest text-fog-faint">perchik</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex flex-1 flex-col gap-1">
|
||||
{nav.map(({ to, label, icon: Icon, end }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={end}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors",
|
||||
isActive
|
||||
? "bg-ink-800 text-fog shadow-[inset_2px_0_0_0] shadow-signal"
|
||||
: "text-fog-muted hover:bg-ink-800/60 hover:text-fog",
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon size={17} />
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<Button variant="ghost" size="sm" className="justify-start" onClick={() => logout.mutate()}>
|
||||
<LogOut size={16} /> Sign out
|
||||
</Button>
|
||||
</aside>
|
||||
|
||||
{/* Main */}
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<header className="sticky top-0 z-30 flex items-center justify-between gap-4 border-b border-ink-700 bg-ink-950/80 px-6 py-3 backdrop-blur">
|
||||
<StatusPill running={status.data?.running ?? null} current={status.data?.current ?? null} />
|
||||
<div className="flex items-center gap-2">
|
||||
{busy ? (
|
||||
<Button variant="danger" size="sm" onClick={() => stop.mutate()} disabled={stop.isPending}>
|
||||
<Square size={14} /> Stop
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="primary" size="sm" onClick={() => run.mutate()} disabled={run.isPending}>
|
||||
<Play size={14} /> Run check
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="min-w-0 flex-1 px-6 py-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/format";
|
||||
|
||||
type ToastKind = "success" | "error" | "info";
|
||||
interface Toast {
|
||||
id: number;
|
||||
kind: ToastKind;
|
||||
message: string;
|
||||
}
|
||||
|
||||
const ToastCtx = React.createContext<(kind: ToastKind, message: string) => void>(() => {});
|
||||
|
||||
export function useToast() {
|
||||
return React.useContext(ToastCtx);
|
||||
}
|
||||
|
||||
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
const [toasts, setToasts] = React.useState<Toast[]>([]);
|
||||
const idRef = React.useRef(0);
|
||||
|
||||
const push = React.useCallback((kind: ToastKind, message: string) => {
|
||||
const id = ++idRef.current;
|
||||
setToasts((t) => [...t, { id, kind, message }]);
|
||||
window.setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), 4000);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ToastCtx.Provider value={push}>
|
||||
{children}
|
||||
<div className="pointer-events-none fixed bottom-4 right-4 z-[60] flex w-80 flex-col gap-2">
|
||||
{toasts.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className={cn(
|
||||
"pointer-events-auto animate-fade-up rounded-lg border px-4 py-3 text-sm shadow-panel backdrop-blur",
|
||||
t.kind === "success" && "border-signal/30 bg-ink-850 text-signal",
|
||||
t.kind === "error" && "border-rose/30 bg-ink-850 text-rose",
|
||||
t.kind === "info" && "border-ink-600 bg-ink-850 text-fog",
|
||||
)}
|
||||
>
|
||||
{t.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ToastCtx.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import * as React from "react";
|
||||
import { cn, fmtInt } from "@/lib/format";
|
||||
import type { Session } from "@/lib/types";
|
||||
|
||||
// SignalBars — the telemetry motif: strength rendered as 4 rising bars. Used
|
||||
// for latency (lower = stronger) so a proxy reads like a radio signal.
|
||||
export function SignalBars({ latencyMs, className }: { latencyMs: number; className?: string }) {
|
||||
// Map latency to a 0–4 strength. <150ms = full, >1000ms = weak.
|
||||
const strength =
|
||||
latencyMs <= 0 ? 0 : latencyMs < 150 ? 4 : latencyMs < 300 ? 3 : latencyMs < 600 ? 2 : latencyMs < 1000 ? 1 : 1;
|
||||
const color = strength >= 3 ? "bg-signal" : strength === 2 ? "bg-amber" : "bg-rose";
|
||||
return (
|
||||
<span className={cn("inline-flex items-end gap-[2px]", className)} aria-label={`${latencyMs}ms`}>
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={cn("w-[3px] rounded-sm", i <= strength ? color : "bg-ink-600")}
|
||||
style={{ height: `${4 + i * 3}px` }}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// StatusPill — the header live indicator. A running check pulses; idle is calm.
|
||||
export function StatusPill({ running, current }: { running: Session | null; current: Session | null }) {
|
||||
if (running) {
|
||||
const pct = running.progress_total > 0 ? Math.round((running.progress_done / running.progress_total) * 100) : 0;
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-full border border-signal/30 bg-signal/10 px-3 py-1">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-signal opacity-60" />
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-signal" />
|
||||
</span>
|
||||
<span className="font-mono text-xs text-signal">
|
||||
CHECKING {fmtInt(running.progress_done)}/{fmtInt(running.progress_total)} · {pct}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-full border border-ink-600 bg-ink-800 px-3 py-1">
|
||||
<span className="h-2 w-2 rounded-full bg-fog-faint" />
|
||||
<span className="font-mono text-xs text-fog-muted">
|
||||
{current ? `IDLE · session #${current.id}` : "NO SESSION"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// SignalSweep — the signature element: a live scanning line over a track,
|
||||
// shown while a check runs. Purely atmospheric; respects reduced-motion.
|
||||
export function SignalSweep({ active }: { active: boolean }) {
|
||||
if (!active) return null;
|
||||
return (
|
||||
<div className="relative h-1 w-full overflow-hidden rounded-full bg-ink-700">
|
||||
<div className="absolute inset-y-0 w-1/4 animate-sweep rounded-full bg-gradient-to-r from-transparent via-signal to-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Stat — a KPI tile. The big number is mono tabular; the delta is optional.
|
||||
export function Stat({
|
||||
label,
|
||||
value,
|
||||
unit,
|
||||
accent = "fog",
|
||||
delta,
|
||||
hint,
|
||||
}: {
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
unit?: string;
|
||||
accent?: "fog" | "signal" | "rose" | "peri" | "amber";
|
||||
delta?: { value: number; positiveGood?: boolean };
|
||||
hint?: string;
|
||||
}) {
|
||||
const accentClass = {
|
||||
fog: "text-fog",
|
||||
signal: "text-signal",
|
||||
rose: "text-rose",
|
||||
peri: "text-peri",
|
||||
amber: "text-amber",
|
||||
}[accent];
|
||||
return (
|
||||
<div className="panel p-4">
|
||||
<div className="eyebrow mb-2">{label}</div>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className={cn("num text-3xl font-semibold", accentClass)}>{value}</span>
|
||||
{unit && <span className="font-mono text-sm text-fog-faint">{unit}</span>}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
{delta !== undefined && <DeltaTag value={delta.value} positiveGood={delta.positiveGood ?? true} />}
|
||||
{hint && <span className="text-xs text-fog-faint">{hint}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeltaTag({ value, positiveGood }: { value: number; positiveGood: boolean }) {
|
||||
if (value === 0) return <span className="font-mono text-xs text-fog-faint">±0</span>;
|
||||
const up = value > 0;
|
||||
const good = up === positiveGood;
|
||||
return (
|
||||
<span className={cn("font-mono text-xs", good ? "text-signal" : "text-rose")}>
|
||||
{up ? "▲" : "▼"} {Math.abs(value)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/format";
|
||||
|
||||
// --- Button ---------------------------------------------------------------
|
||||
|
||||
type ButtonVariant = "primary" | "ghost" | "outline" | "danger" | "subtle";
|
||||
type ButtonSize = "sm" | "md" | "icon";
|
||||
|
||||
const buttonVariants: Record<ButtonVariant, string> = {
|
||||
primary: "bg-signal text-ink-950 hover:bg-signal/90 font-medium",
|
||||
danger: "bg-rose/90 text-ink-950 hover:bg-rose font-medium",
|
||||
outline: "border border-ink-600 text-fog hover:border-ink-500 hover:bg-ink-800",
|
||||
ghost: "text-fog-muted hover:text-fog hover:bg-ink-800",
|
||||
subtle: "bg-ink-700 text-fog hover:bg-ink-600",
|
||||
};
|
||||
const buttonSizes: Record<ButtonSize, string> = {
|
||||
sm: "h-8 px-3 text-xs gap-1.5",
|
||||
md: "h-9 px-4 text-sm gap-2",
|
||||
icon: "h-8 w-8 justify-center",
|
||||
};
|
||||
|
||||
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
}
|
||||
|
||||
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant = "subtle", size = "md", ...props }, ref) => (
|
||||
<button
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-lg transition-colors disabled:opacity-40 disabled:pointer-events-none whitespace-nowrap",
|
||||
buttonVariants[variant],
|
||||
buttonSizes[size],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
// --- Card -----------------------------------------------------------------
|
||||
|
||||
export function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("panel p-5", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardTitle({ eyebrow, title, right }: { eyebrow?: string; title: string; right?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="mb-4 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
{eyebrow && <div className="eyebrow mb-1">{eyebrow}</div>}
|
||||
<h3 className="font-display text-base font-semibold text-fog">{title}</h3>
|
||||
</div>
|
||||
{right}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Input / Select -------------------------------------------------------
|
||||
|
||||
export const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-9 w-full rounded-lg border border-ink-700 bg-ink-900 px-3 text-sm text-fog placeholder:text-fog-faint",
|
||||
"focus:border-signal/50 transition-colors",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
|
||||
export const Select = React.forwardRef<HTMLSelectElement, React.SelectHTMLAttributes<HTMLSelectElement>>(
|
||||
({ className, children, ...props }, ref) => (
|
||||
<select
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-9 w-full rounded-lg border border-ink-700 bg-ink-900 px-3 text-sm text-fog",
|
||||
"focus:border-signal/50 transition-colors",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
),
|
||||
);
|
||||
Select.displayName = "Select";
|
||||
|
||||
export function Label({ className, ...props }: React.LabelHTMLAttributes<HTMLLabelElement>) {
|
||||
return <label className={cn("eyebrow mb-1.5 block", className)} {...props} />;
|
||||
}
|
||||
|
||||
// --- Toggle ---------------------------------------------------------------
|
||||
|
||||
export function Toggle({
|
||||
checked,
|
||||
onChange,
|
||||
label,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
label?: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={label}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={cn(
|
||||
"relative h-6 w-11 shrink-0 rounded-full transition-colors",
|
||||
checked ? "bg-signal" : "bg-ink-600",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"absolute top-0.5 h-5 w-5 rounded-full bg-ink-950 transition-transform",
|
||||
checked ? "translate-x-[22px]" : "translate-x-0.5",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Badge ----------------------------------------------------------------
|
||||
|
||||
export function Badge({
|
||||
children,
|
||||
color = "peri",
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
color?: "signal" | "rose" | "peri" | "amber" | "muted";
|
||||
className?: string;
|
||||
}) {
|
||||
const map = {
|
||||
signal: "bg-signal/15 text-signal border-signal/25",
|
||||
rose: "bg-rose/15 text-rose border-rose/25",
|
||||
peri: "bg-peri/15 text-peri border-peri/25",
|
||||
amber: "bg-amber/15 text-amber border-amber/25",
|
||||
muted: "bg-ink-700 text-fog-muted border-ink-600",
|
||||
};
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-md border px-2 py-0.5 font-mono text-[11px] uppercase tracking-wide",
|
||||
map[color],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Modal ----------------------------------------------------------------
|
||||
|
||||
export function Modal({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
wide,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
wide?: boolean;
|
||||
}) {
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-ink-950/70 p-4 backdrop-blur-sm">
|
||||
<div
|
||||
className={cn("panel mt-16 w-full animate-fade-up p-6", wide ? "max-w-2xl" : "max-w-md")}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div className="mb-5 flex items-center justify-between">
|
||||
<h3 className="font-display text-lg font-semibold">{title}</h3>
|
||||
<Button variant="ghost" size="icon" onClick={onClose} aria-label="Close">
|
||||
✕
|
||||
</Button>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Spinner / Empty ------------------------------------------------------
|
||||
|
||||
export function Spinner({ className }: { className?: string }) {
|
||||
return (
|
||||
<div className={cn("flex items-center justify-center py-16 text-fog-faint", className)}>
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-ink-600 border-t-signal" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyState({ title, hint }: { title: string; hint?: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-1 py-16 text-center">
|
||||
<p className="font-display text-fog-muted">{title}</p>
|
||||
{hint && <p className="text-sm text-fog-faint">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
body {
|
||||
@apply bg-ink-950 text-fog font-sans antialiased;
|
||||
background-image:
|
||||
radial-gradient(60rem 40rem at 100% -10%, rgba(124, 140, 248, 0.08), transparent 60%),
|
||||
radial-gradient(50rem 30rem at -10% 110%, rgba(94, 230, 196, 0.06), transparent 55%);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
::selection {
|
||||
@apply bg-signal/30 text-fog;
|
||||
}
|
||||
/* Focus ring — visible for keyboard users. */
|
||||
:focus-visible {
|
||||
@apply outline-none ring-2 ring-signal/60 ring-offset-2 ring-offset-ink-950;
|
||||
}
|
||||
/* Thin, quiet scrollbars fit the console aesthetic. */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #28324a transparent;
|
||||
}
|
||||
*::-webkit-scrollbar {
|
||||
height: 8px;
|
||||
width: 8px;
|
||||
}
|
||||
*::-webkit-scrollbar-thumb {
|
||||
@apply bg-ink-600 rounded-full;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.panel {
|
||||
@apply rounded-xl border border-ink-700 bg-ink-850/80 shadow-panel backdrop-blur-sm;
|
||||
}
|
||||
.eyebrow {
|
||||
@apply font-mono text-[11px] uppercase tracking-[0.18em] text-fog-faint;
|
||||
}
|
||||
.num {
|
||||
@apply font-mono tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.001ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.001ms !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import type {
|
||||
CheckerStatus,
|
||||
Dashboard,
|
||||
Facets,
|
||||
Proxy,
|
||||
Session,
|
||||
Settings,
|
||||
Source,
|
||||
Subscription,
|
||||
} from "./types";
|
||||
|
||||
// ApiError carries the HTTP status so callers (e.g. auth) can branch on 401.
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
method,
|
||||
credentials: "include",
|
||||
headers: body !== undefined ? { "Content-Type": "application/json" } : undefined,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (!res.ok) {
|
||||
let msg = res.statusText;
|
||||
try {
|
||||
const data = await res.json();
|
||||
if (data?.error) msg = data.error;
|
||||
} catch {
|
||||
/* ignore non-JSON error bodies */
|
||||
}
|
||||
throw new ApiError(res.status, msg);
|
||||
}
|
||||
if (res.status === 204) return undefined as T;
|
||||
const ct = res.headers.get("Content-Type") || "";
|
||||
if (ct.includes("application/json")) return res.json() as Promise<T>;
|
||||
return res.text() as unknown as Promise<T>;
|
||||
}
|
||||
|
||||
export interface ProxyListResponse {
|
||||
items: Proxy[];
|
||||
total: number;
|
||||
session: Session | null;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
// auth
|
||||
login: (username: string, password: string) =>
|
||||
request<{ token: string; user: string }>("POST", "/api/v1/auth/login", { username, password }),
|
||||
logout: () => request<{ status: string }>("POST", "/api/v1/auth/logout"),
|
||||
me: () => request<{ user: string }>("GET", "/api/v1/auth/me"),
|
||||
|
||||
// dashboard
|
||||
dashboard: () => request<Dashboard>("GET", "/api/v1/dashboard"),
|
||||
|
||||
// proxies
|
||||
proxies: (query: string) => request<ProxyListResponse>("GET", `/api/v1/proxies?${query}`),
|
||||
proxyFacets: () => request<Facets>("GET", "/api/v1/proxies/facets"),
|
||||
recheckProxies: (ids: number[]) => request<{ queued: number }>("POST", "/api/v1/proxies/recheck", { ids }),
|
||||
deleteProxies: (ids: number[]) => request<{ deleted: number }>("POST", "/api/v1/proxies/delete", { ids }),
|
||||
|
||||
// subscriptions
|
||||
subscriptions: () => request<{ items: Subscription[] }>("GET", "/api/v1/subscriptions"),
|
||||
createSubscription: (body: Partial<Subscription>) =>
|
||||
request<Subscription>("POST", "/api/v1/subscriptions", body),
|
||||
updateSubscription: (id: number, body: Partial<Subscription>) =>
|
||||
request<Subscription>("PATCH", `/api/v1/subscriptions/${id}`, body),
|
||||
deleteSubscription: (id: number) => request<{ status: string }>("DELETE", `/api/v1/subscriptions/${id}`),
|
||||
|
||||
// sources
|
||||
sources: () => request<{ items: Source[] }>("GET", "/api/v1/sources"),
|
||||
createSource: (body: { name: string; url: string; enabled: boolean }) =>
|
||||
request<{ id: number }>("POST", "/api/v1/sources", body),
|
||||
updateSource: (id: number, body: { name: string; url: string; enabled: boolean }) =>
|
||||
request<{ status: string }>("PATCH", `/api/v1/sources/${id}`, body),
|
||||
deleteSource: (id: number) => request<{ status: string }>("DELETE", `/api/v1/sources/${id}`),
|
||||
|
||||
// settings
|
||||
settings: () => request<Settings>("GET", "/api/v1/settings"),
|
||||
updateSettings: (body: Settings) => request<{ status: string }>("PATCH", "/api/v1/settings", body),
|
||||
|
||||
// checker
|
||||
checkerStatus: () => request<CheckerStatus>("GET", "/api/v1/checker/status"),
|
||||
checkerRun: () => request<{ status: string }>("POST", "/api/v1/checker/run"),
|
||||
checkerStop: () => request<{ status: string }>("POST", "/api/v1/checker/stop"),
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export function fmtInt(n: number | undefined | null): string {
|
||||
if (n === undefined || n === null) return "0";
|
||||
return n.toLocaleString("en-US");
|
||||
}
|
||||
|
||||
export function fmtPct(n: number | undefined | null, digits = 1): string {
|
||||
if (n === undefined || n === null) return "0%";
|
||||
return `${n.toFixed(digits)}%`;
|
||||
}
|
||||
|
||||
export function fmtSpeed(mbps: number | undefined | null): string {
|
||||
if (!mbps) return "0";
|
||||
return mbps >= 100 ? mbps.toFixed(0) : mbps.toFixed(1);
|
||||
}
|
||||
|
||||
export function fmtMs(ms: number | undefined | null): string {
|
||||
if (ms === undefined || ms === null) return "—";
|
||||
if (ms >= 1000) return `${(ms / 1000).toFixed(1)}s`;
|
||||
return `${ms}ms`;
|
||||
}
|
||||
|
||||
export function fmtDuration(ms: number | undefined | null): string {
|
||||
if (!ms) return "—";
|
||||
const s = Math.round(ms / 1000);
|
||||
if (s < 60) return `${s}s`;
|
||||
const m = Math.floor(s / 60);
|
||||
const rem = s % 60;
|
||||
if (m < 60) return `${m}m ${rem}s`;
|
||||
const h = Math.floor(m / 60);
|
||||
return `${h}h ${m % 60}m`;
|
||||
}
|
||||
|
||||
export function fmtAgo(iso: string | null | undefined): string {
|
||||
if (!iso) return "never";
|
||||
const then = new Date(iso).getTime();
|
||||
const diff = Date.now() - then;
|
||||
const s = Math.round(diff / 1000);
|
||||
if (s < 60) return `${s}s ago`;
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
const d = Math.floor(h / 24);
|
||||
return `${d}d ago`;
|
||||
}
|
||||
|
||||
export function fmtDateTime(iso: string | null | undefined): string {
|
||||
if (!iso) return "—";
|
||||
return new Date(iso).toLocaleString();
|
||||
}
|
||||
|
||||
// countryFlag converts a 2-letter ISO code to a regional-indicator emoji.
|
||||
export function countryFlag(code: string): string {
|
||||
if (!code || code.length !== 2 || code === "XX") return "🏳️";
|
||||
const cc = code.toUpperCase();
|
||||
const A = 0x1f1e6;
|
||||
return String.fromCodePoint(A + (cc.charCodeAt(0) - 65), A + (cc.charCodeAt(1) - 65));
|
||||
}
|
||||
|
||||
export const PROTO_COLORS: Record<string, string> = {
|
||||
vless: "#5EE6C4",
|
||||
vmess: "#7C8CF8",
|
||||
trojan: "#F5B855",
|
||||
ss: "#F0708A",
|
||||
};
|
||||
@@ -0,0 +1,141 @@
|
||||
// Types mirror the Go API JSON shapes.
|
||||
|
||||
export interface Session {
|
||||
id: number;
|
||||
status: string;
|
||||
trigger_kind: string;
|
||||
is_current: boolean;
|
||||
cancel_requested: boolean;
|
||||
started_at: string;
|
||||
finished_at: string | null;
|
||||
duration_ms: number;
|
||||
total_raw_lines: number;
|
||||
unique_candidates: number;
|
||||
collapsed: number;
|
||||
progress_total: number;
|
||||
progress_done: number;
|
||||
valid: number;
|
||||
invalid: number;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface Proxy {
|
||||
proxy_id: number;
|
||||
url: string;
|
||||
protocol: string;
|
||||
host: string;
|
||||
port: number;
|
||||
source: string;
|
||||
latency_ms: number;
|
||||
speed_mbps: number;
|
||||
country: string;
|
||||
exit_ip: string;
|
||||
is_new: boolean;
|
||||
first_seen?: string | null;
|
||||
last_alive?: string | null;
|
||||
consecutive_failures?: number;
|
||||
}
|
||||
|
||||
export interface Source {
|
||||
id: number;
|
||||
name: string;
|
||||
url: string;
|
||||
enabled: boolean;
|
||||
last_fetched_at: string | null;
|
||||
last_line_count: number;
|
||||
last_error: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Subscription {
|
||||
id: number;
|
||||
token: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
format: string;
|
||||
filter_protocols: string[];
|
||||
filter_countries: string[];
|
||||
filter_sources: string[];
|
||||
max_latency_ms: number | null;
|
||||
min_speed_mbps: number | null;
|
||||
limit_n: number | null;
|
||||
unique_ips: boolean;
|
||||
unique_ips_metric: string;
|
||||
sort_by: string[];
|
||||
expires_at: string | null;
|
||||
request_count: number;
|
||||
last_requested_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface LabelCount {
|
||||
label: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface ProtocolStat {
|
||||
protocol: string;
|
||||
checked: number;
|
||||
passed: number;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
export interface SourceStat {
|
||||
source: string;
|
||||
raw_lines: number;
|
||||
unique_candidates: number;
|
||||
passed: number;
|
||||
}
|
||||
|
||||
export interface UptimeRow {
|
||||
url: string;
|
||||
protocol: string;
|
||||
country: string;
|
||||
total_checks: number;
|
||||
total_passes: number;
|
||||
uptime_pct: number;
|
||||
alive_days: number;
|
||||
}
|
||||
|
||||
export interface Dashboard {
|
||||
current: Session | null;
|
||||
running?: Session | null;
|
||||
sessions?: Session[];
|
||||
summary?: {
|
||||
valid: number;
|
||||
invalid: number;
|
||||
total_checked: number;
|
||||
valid_pct: number;
|
||||
working_total: number;
|
||||
collapsed: number;
|
||||
raw_lines: number;
|
||||
unique: number;
|
||||
duration_ms: number;
|
||||
finished_at: string | null;
|
||||
};
|
||||
protocols?: ProtocolStat[];
|
||||
countries?: LabelCount[];
|
||||
sources?: SourceStat[];
|
||||
latency_buckets?: LabelCount[];
|
||||
speed_buckets?: LabelCount[];
|
||||
latency_avg?: number;
|
||||
latency_median?: number;
|
||||
speed_avg?: number;
|
||||
speed_median?: number;
|
||||
uptime?: UptimeRow[];
|
||||
dynamics?: { added: number; dropped: number };
|
||||
}
|
||||
|
||||
export interface CheckerStatus {
|
||||
busy: boolean;
|
||||
running: Session | null;
|
||||
current: Session | null;
|
||||
}
|
||||
|
||||
export type Settings = Record<string, string>;
|
||||
|
||||
export interface Facets {
|
||||
protocols: string[];
|
||||
countries: string[];
|
||||
sources: string[];
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import App from "./App";
|
||||
import { ToastProvider } from "./components/Toast";
|
||||
import "./index.css";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: 1, refetchOnWindowFocus: false, staleTime: 5000 },
|
||||
},
|
||||
});
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<ToastProvider>
|
||||
<App />
|
||||
</ToastProvider>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,304 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { api } from "@/lib/api";
|
||||
import { Card, CardTitle, EmptyState, Spinner } from "@/components/ui";
|
||||
import { SignalSweep, Stat } from "@/components/telemetry";
|
||||
import { PROTO_COLORS, countryFlag, fmtDuration, fmtInt, fmtMs, fmtPct, fmtSpeed } from "@/lib/format";
|
||||
import type { Session } from "@/lib/types";
|
||||
|
||||
const AXIS = { stroke: "#5A6478", fontSize: 11, fontFamily: "JetBrains Mono" };
|
||||
|
||||
function ChartTip({ active, payload, label }: any) {
|
||||
if (!active || !payload?.length) return null;
|
||||
return (
|
||||
<div className="panel px-3 py-2 text-xs">
|
||||
<div className="mb-1 font-mono text-fog-muted">{label}</div>
|
||||
{payload.map((p: any) => (
|
||||
<div key={p.name} className="flex items-center gap-2 font-mono">
|
||||
<span className="h-2 w-2 rounded-sm" style={{ background: p.color || p.fill }} />
|
||||
<span className="text-fog-muted">{p.name}</span>
|
||||
<span className="text-fog">{fmtInt(p.value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const q = useQuery({ queryKey: ["dashboard"], queryFn: api.dashboard, refetchInterval: 5000 });
|
||||
|
||||
if (q.isLoading) return <Spinner />;
|
||||
const d = q.data;
|
||||
if (!d) return <EmptyState title="No data" />;
|
||||
|
||||
if (!d.current && !d.running) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="No completed check yet"
|
||||
hint="Add sources in Settings, then hit Run check. Results appear here once the first session finishes."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const s = d.summary;
|
||||
const running = d.running ?? null;
|
||||
const trend = (d.sessions ?? [])
|
||||
.filter((x) => x.status === "completed")
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((x: Session) => ({
|
||||
id: `#${x.id}`,
|
||||
valid: x.valid,
|
||||
invalid: x.invalid,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-end justify-between">
|
||||
<div>
|
||||
<div className="eyebrow mb-1">Network overview</div>
|
||||
<h1 className="font-display text-2xl font-bold tracking-tight">Dashboard</h1>
|
||||
</div>
|
||||
{d.current && (
|
||||
<div className="text-right font-mono text-xs text-fog-faint">
|
||||
<div>session #{d.current.id} · {d.current.trigger_kind}</div>
|
||||
<div>checked in {fmtDuration(d.current.duration_ms)}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Live progress */}
|
||||
{running && (
|
||||
<Card className="border-signal/25">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="eyebrow text-signal">Live · session #{running.id}</div>
|
||||
<div className="num text-sm text-fog-muted">
|
||||
{fmtInt(running.progress_done)} / {fmtInt(running.progress_total)} checked ·{" "}
|
||||
<span className="text-signal">{fmtInt(running.valid)} valid</span> ·{" "}
|
||||
<span className="text-rose">{fmtInt(running.invalid)} invalid</span>
|
||||
</div>
|
||||
</div>
|
||||
<SignalSweep active />
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Hero KPI row */}
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-6">
|
||||
<Stat
|
||||
label="Working proxies"
|
||||
value={fmtInt(s?.working_total)}
|
||||
accent="signal"
|
||||
delta={d.dynamics ? { value: d.dynamics.added - d.dynamics.dropped } : undefined}
|
||||
hint={d.dynamics ? `+${d.dynamics.added} / -${d.dynamics.dropped}` : undefined}
|
||||
/>
|
||||
<Stat label="Valid rate" value={fmtPct(s?.valid_pct)} accent="peri" />
|
||||
<Stat label="Invalid" value={fmtInt(s?.invalid)} accent="rose" hint="last session" />
|
||||
<Stat label="Avg latency" value={fmtMs(Math.round(d.latency_avg ?? 0))} accent="amber" hint={`median ${fmtMs(Math.round(d.latency_median ?? 0))}`} />
|
||||
<Stat label="Avg speed" value={fmtSpeed(d.speed_avg)} unit="Mbps" accent="signal" hint={`median ${fmtSpeed(d.speed_median)}`} />
|
||||
<Stat label="Deduped" value={fmtInt(s?.collapsed)} hint={`${fmtInt(s?.raw_lines)} raw → ${fmtInt(s?.unique)} unique`} />
|
||||
</div>
|
||||
|
||||
{/* Trends + protocol split */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<Card className="lg:col-span-2">
|
||||
<CardTitle eyebrow="Per session" title="Valid vs invalid trend" />
|
||||
{trend.length === 0 ? (
|
||||
<EmptyState title="Not enough history yet" />
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<AreaChart data={trend} margin={{ left: -18, right: 8, top: 6 }}>
|
||||
<defs>
|
||||
<linearGradient id="gValid" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#5EE6C4" stopOpacity={0.5} />
|
||||
<stop offset="100%" stopColor="#5EE6C4" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient id="gInvalid" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#F0708A" stopOpacity={0.4} />
|
||||
<stop offset="100%" stopColor="#F0708A" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid stroke="#1E2635" vertical={false} />
|
||||
<XAxis dataKey="id" tick={AXIS} tickLine={false} axisLine={false} />
|
||||
<YAxis tick={AXIS} tickLine={false} axisLine={false} width={44} />
|
||||
<Tooltip content={<ChartTip />} />
|
||||
<Area type="monotone" dataKey="valid" stroke="#5EE6C4" strokeWidth={2} fill="url(#gValid)" />
|
||||
<Area type="monotone" dataKey="invalid" stroke="#F0708A" strokeWidth={2} fill="url(#gInvalid)" />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardTitle eyebrow="By protocol" title="Valid share" />
|
||||
<div className="space-y-3">
|
||||
{(d.protocols ?? []).length === 0 && <EmptyState title="—" />}
|
||||
{(d.protocols ?? []).map((p) => {
|
||||
const pct = p.checked > 0 ? (p.passed / p.checked) * 100 : 0;
|
||||
return (
|
||||
<div key={p.protocol}>
|
||||
<div className="mb-1 flex items-center justify-between font-mono text-xs">
|
||||
<span className="uppercase" style={{ color: PROTO_COLORS[p.protocol] ?? "#8A93A6" }}>
|
||||
{p.protocol}
|
||||
</span>
|
||||
<span className="text-fog-muted">
|
||||
{fmtInt(p.passed)}/{fmtInt(p.checked)} · {fmtPct(pct, 0)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-ink-700">
|
||||
<div
|
||||
className="h-full rounded-full"
|
||||
style={{ width: `${pct}%`, background: PROTO_COLORS[p.protocol] ?? "#8A93A6" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Distributions */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardTitle eyebrow="Milliseconds" title="Latency distribution" />
|
||||
<BucketChart data={d.latency_buckets ?? []} color="#F5B855" />
|
||||
</Card>
|
||||
<Card>
|
||||
<CardTitle eyebrow="Mbps" title="Speed distribution" />
|
||||
<BucketChart data={d.speed_buckets ?? []} color="#5EE6C4" />
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Countries + sources */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardTitle eyebrow="By exit country" title="Top locations" />
|
||||
<div className="space-y-2">
|
||||
{(d.countries ?? []).length === 0 && <EmptyState title="—" />}
|
||||
{(d.countries ?? []).slice(0, 10).map((c) => {
|
||||
const max = d.countries![0]?.count || 1;
|
||||
return (
|
||||
<div key={c.label} className="flex items-center gap-3">
|
||||
<span className="w-16 shrink-0 font-mono text-xs text-fog-muted">
|
||||
{countryFlag(c.label)} {c.label}
|
||||
</span>
|
||||
<div className="h-4 flex-1 overflow-hidden rounded bg-ink-700">
|
||||
<div className="h-full rounded bg-peri/70" style={{ width: `${(c.count / max) * 100}%` }} />
|
||||
</div>
|
||||
<span className="num w-12 shrink-0 text-right text-xs text-fog">{fmtInt(c.count)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardTitle eyebrow="By source" title="Yield" />
|
||||
<div className="overflow-hidden">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="eyebrow border-b border-ink-700 text-fog-faint">
|
||||
<th className="pb-2 font-normal">Source</th>
|
||||
<th className="pb-2 text-right font-normal">Raw</th>
|
||||
<th className="pb-2 text-right font-normal">Unique</th>
|
||||
<th className="pb-2 text-right font-normal">Valid</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="font-mono text-xs">
|
||||
{(d.sources ?? []).length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="py-6 text-center text-fog-faint">
|
||||
—
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{(d.sources ?? []).map((sc) => (
|
||||
<tr key={sc.source} className="border-b border-ink-800/70">
|
||||
<td className="max-w-[180px] truncate py-2 pr-2 text-fog" title={sc.source}>
|
||||
{sc.source}
|
||||
</td>
|
||||
<td className="py-2 text-right text-fog-muted">{fmtInt(sc.raw_lines)}</td>
|
||||
<td className="py-2 text-right text-fog-muted">{fmtInt(sc.unique_candidates)}</td>
|
||||
<td className="py-2 text-right text-signal">{fmtInt(sc.passed)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Uptime */}
|
||||
<Card>
|
||||
<CardTitle eyebrow="Longevity" title="Longest-lived proxies" />
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="eyebrow border-b border-ink-700 text-fog-faint">
|
||||
<th className="pb-2 font-normal">Proxy</th>
|
||||
<th className="pb-2 font-normal">Proto</th>
|
||||
<th className="pb-2 font-normal">Geo</th>
|
||||
<th className="pb-2 text-right font-normal">Alive</th>
|
||||
<th className="pb-2 text-right font-normal">Uptime</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="font-mono text-xs">
|
||||
{(d.uptime ?? []).length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="py-6 text-center text-fog-faint">
|
||||
—
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{(d.uptime ?? []).map((u, i) => (
|
||||
<tr key={i} className="border-b border-ink-800/70">
|
||||
<td className="max-w-[320px] truncate py-2 pr-2 text-fog" title={u.url}>
|
||||
{u.url}
|
||||
</td>
|
||||
<td className="py-2 uppercase" style={{ color: PROTO_COLORS[u.protocol] ?? "#8A93A6" }}>
|
||||
{u.protocol}
|
||||
</td>
|
||||
<td className="py-2 text-fog-muted">
|
||||
{countryFlag(u.country)} {u.country || "—"}
|
||||
</td>
|
||||
<td className="py-2 text-right text-fog-muted">{u.alive_days.toFixed(1)}d</td>
|
||||
<td className="py-2 text-right text-signal">{fmtPct(u.uptime_pct * 100, 0)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BucketChart({ data, color }: { data: { label: string; count: number }[]; color: string }) {
|
||||
if (data.length === 0) return <EmptyState title="—" />;
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<BarChart data={data} margin={{ left: -18, right: 8, top: 6 }}>
|
||||
<CartesianGrid stroke="#1E2635" vertical={false} />
|
||||
<XAxis dataKey="label" tick={AXIS} tickLine={false} axisLine={false} />
|
||||
<YAxis tick={AXIS} tickLine={false} axisLine={false} width={44} />
|
||||
<Tooltip content={<ChartTip />} cursor={{ fill: "#171F2E" }} />
|
||||
<Bar dataKey="count" radius={[4, 4, 0, 0]}>
|
||||
{data.map((_, i) => (
|
||||
<Cell key={i} fill={color} fillOpacity={0.85} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import * as React from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Radio } from "lucide-react";
|
||||
import { api } from "@/lib/api";
|
||||
import { Button, Input, Label } from "@/components/ui";
|
||||
import { SignalBars } from "@/components/telemetry";
|
||||
|
||||
export default function Login() {
|
||||
const [username, setUsername] = React.useState("");
|
||||
const [password, setPassword] = React.useState("");
|
||||
const qc = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const login = useMutation({
|
||||
mutationFn: () => api.login(username, password),
|
||||
onSuccess: async () => {
|
||||
await qc.invalidateQueries({ queryKey: ["me"] });
|
||||
navigate("/");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-4">
|
||||
<div className="w-full max-w-sm">
|
||||
{/* Signature: the login card reads like a console terminal handshaking. */}
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-signal/15 text-signal shadow-glow">
|
||||
<Radio size={22} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="font-display text-xl font-bold tracking-tight">zhguchiy_perchik</h1>
|
||||
<div className="flex items-center gap-2 font-mono text-[11px] uppercase tracking-widest text-fog-faint">
|
||||
proxy console <SignalBars latencyMs={120} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="panel space-y-4 p-6"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
login.mutate();
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Label htmlFor="username">Operator</Label>
|
||||
<Input
|
||||
id="username"
|
||||
autoFocus
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="admin"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="password">Passphrase</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{login.isError && (
|
||||
<p className="font-mono text-xs text-rose">
|
||||
{(login.error as Error).message || "Sign-in failed"}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button type="submit" variant="primary" className="w-full justify-center" disabled={login.isPending}>
|
||||
{login.isPending ? "Connecting…" : "Enter console"}
|
||||
</Button>
|
||||
</form>
|
||||
<p className="mt-4 text-center font-mono text-[11px] text-fog-faint">
|
||||
single-operator access · session secured
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import * as React from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Copy, Download, RotateCw, Trash2, Search } from "lucide-react";
|
||||
import { api } from "@/lib/api";
|
||||
import { Badge, Button, EmptyState, Input, Select, Spinner, Toggle } from "@/components/ui";
|
||||
import { SignalBars } from "@/components/telemetry";
|
||||
import { useToast } from "@/components/Toast";
|
||||
import { PROTO_COLORS, cn, countryFlag, fmtAgo, fmtInt, fmtSpeed } from "@/lib/format";
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
export default function Proxies() {
|
||||
const toast = useToast();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const [protocol, setProtocol] = React.useState("");
|
||||
const [country, setCountry] = React.useState("");
|
||||
const [source, setSource] = React.useState("");
|
||||
const [maxLatency, setMaxLatency] = React.useState("");
|
||||
const [minSpeed, setMinSpeed] = React.useState("");
|
||||
const [search, setSearch] = React.useState("");
|
||||
const [uniqueIPs, setUniqueIPs] = React.useState(false);
|
||||
const [metric, setMetric] = React.useState("speed");
|
||||
const [page, setPage] = React.useState(0);
|
||||
const [selected, setSelected] = React.useState<Set<number>>(new Set());
|
||||
|
||||
const facets = useQuery({ queryKey: ["facets"], queryFn: api.proxyFacets });
|
||||
|
||||
const params = React.useMemo(() => {
|
||||
const p = new URLSearchParams();
|
||||
if (protocol) p.set("protocol", protocol);
|
||||
if (country) p.set("country", country);
|
||||
if (source) p.set("source", source);
|
||||
if (maxLatency) p.set("max_latency_ms", maxLatency);
|
||||
if (minSpeed) p.set("min_speed_mbps", minSpeed);
|
||||
if (search) p.set("search", search);
|
||||
if (uniqueIPs) {
|
||||
p.set("unique_ips", "true");
|
||||
p.set("metric", metric);
|
||||
}
|
||||
p.set("limit", String(PAGE_SIZE));
|
||||
p.set("offset", String(page * PAGE_SIZE));
|
||||
return p.toString();
|
||||
}, [protocol, country, source, maxLatency, minSpeed, search, uniqueIPs, metric, page]);
|
||||
|
||||
const list = useQuery({ queryKey: ["proxies", params], queryFn: () => api.proxies(params) });
|
||||
|
||||
React.useEffect(() => setPage(0), [protocol, country, source, maxLatency, minSpeed, search, uniqueIPs, metric]);
|
||||
|
||||
const items = list.data?.items ?? [];
|
||||
const total = list.data?.total ?? 0;
|
||||
const pages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
|
||||
const recheck = useMutation({
|
||||
mutationFn: (ids: number[]) => api.recheckProxies(ids),
|
||||
onSuccess: (r) => {
|
||||
toast("success", `Queued ${r.queued} for recheck`);
|
||||
setSelected(new Set());
|
||||
},
|
||||
onError: (e: Error) => toast("error", e.message),
|
||||
});
|
||||
const del = useMutation({
|
||||
mutationFn: (ids: number[]) => api.deleteProxies(ids),
|
||||
onSuccess: (r) => {
|
||||
toast("success", `Removed ${r.deleted} from current view`);
|
||||
setSelected(new Set());
|
||||
qc.invalidateQueries({ queryKey: ["proxies"] });
|
||||
},
|
||||
onError: (e: Error) => toast("error", e.message),
|
||||
});
|
||||
|
||||
function toggleSel(id: number) {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
function toggleAll() {
|
||||
setSelected((prev) => (prev.size === items.length ? new Set() : new Set(items.map((p) => p.proxy_id))));
|
||||
}
|
||||
function copyOne(url: string) {
|
||||
navigator.clipboard.writeText(url).then(() => toast("info", "Copied"));
|
||||
}
|
||||
function exportTxt() {
|
||||
const p = new URLSearchParams(params);
|
||||
p.delete("limit");
|
||||
p.delete("offset");
|
||||
window.open(`/api/v1/proxies/export.txt?${p.toString()}`, "_blank");
|
||||
}
|
||||
|
||||
const selCount = selected.size;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<div className="eyebrow mb-1">Current session · working only</div>
|
||||
<h1 className="font-display text-2xl font-bold tracking-tight">Proxies</h1>
|
||||
</div>
|
||||
<div className="num text-sm text-fog-muted">{fmtInt(total)} shown</div>
|
||||
</div>
|
||||
|
||||
{/* Filter bar */}
|
||||
<div className="panel flex flex-wrap items-end gap-3 p-4">
|
||||
<div className="relative min-w-[200px] flex-1">
|
||||
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-fog-faint" />
|
||||
<Input
|
||||
className="pl-9"
|
||||
placeholder="Search url / host / exit IP"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Select value={protocol} onChange={(e) => setProtocol(e.target.value)} className="w-32">
|
||||
<option value="">All proto</option>
|
||||
{(facets.data?.protocols ?? []).map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{p}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Select value={country} onChange={(e) => setCountry(e.target.value)} className="w-32">
|
||||
<option value="">All geo</option>
|
||||
{(facets.data?.countries ?? []).map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Select value={source} onChange={(e) => setSource(e.target.value)} className="w-40">
|
||||
<option value="">All sources</option>
|
||||
{(facets.data?.sources ?? []).map((sname) => (
|
||||
<option key={sname} value={sname}>
|
||||
{sname}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Input
|
||||
type="number"
|
||||
className="w-28"
|
||||
placeholder="max ms"
|
||||
value={maxLatency}
|
||||
onChange={(e) => setMaxLatency(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
className="w-28"
|
||||
placeholder="min Mbps"
|
||||
value={minSpeed}
|
||||
onChange={(e) => setMinSpeed(e.target.value)}
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={exportTxt}>
|
||||
<Download size={14} /> .txt
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* unique_ips + bulk actions */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2 rounded-lg border border-ink-700 bg-ink-850 px-3 py-1.5">
|
||||
<Toggle checked={uniqueIPs} onChange={setUniqueIPs} label="Unique exit IPs" />
|
||||
<span className="text-sm text-fog-muted">unique_ips</span>
|
||||
{uniqueIPs && (
|
||||
<Select value={metric} onChange={(e) => setMetric(e.target.value)} className="h-7 w-28 text-xs">
|
||||
<option value="speed">by speed</option>
|
||||
<option value="latency">by latency</option>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{selCount > 0 && <span className="num text-xs text-fog-muted">{selCount} selected</span>}
|
||||
<Button variant="outline" size="sm" disabled={selCount === 0} onClick={() => recheck.mutate([...selected])}>
|
||||
<RotateCw size={14} /> Recheck
|
||||
</Button>
|
||||
<Button variant="danger" size="sm" disabled={selCount === 0} onClick={() => del.mutate([...selected])}>
|
||||
<Trash2 size={14} /> Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="panel overflow-hidden p-0">
|
||||
{list.isLoading ? (
|
||||
<Spinner />
|
||||
) : items.length === 0 ? (
|
||||
<EmptyState title="No proxies match" hint="Adjust filters or run a check." />
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="eyebrow border-b border-ink-700 text-fog-faint">
|
||||
<th className="w-10 py-3 pl-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-signal"
|
||||
checked={selected.size === items.length && items.length > 0}
|
||||
onChange={toggleAll}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
</th>
|
||||
<th className="py-3 font-normal">Proxy</th>
|
||||
<th className="py-3 font-normal">Proto</th>
|
||||
<th className="py-3 font-normal">Geo</th>
|
||||
<th className="py-3 font-normal">Exit IP</th>
|
||||
<th className="py-3 text-right font-normal">Latency</th>
|
||||
<th className="py-3 text-right font-normal">Speed</th>
|
||||
<th className="py-3 font-normal">Source</th>
|
||||
<th className="py-3 text-right font-normal">Seen</th>
|
||||
<th className="py-3 pr-4"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="font-mono text-xs">
|
||||
{items.map((p) => (
|
||||
<tr
|
||||
key={p.proxy_id}
|
||||
className={cn(
|
||||
"border-b border-ink-800/60 transition-colors hover:bg-ink-800/40",
|
||||
selected.has(p.proxy_id) && "bg-signal/5",
|
||||
)}
|
||||
>
|
||||
<td className="py-2.5 pl-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-signal"
|
||||
checked={selected.has(p.proxy_id)}
|
||||
onChange={() => toggleSel(p.proxy_id)}
|
||||
aria-label="Select"
|
||||
/>
|
||||
</td>
|
||||
<td className="max-w-[260px] truncate py-2.5 pr-3 text-fog" title={p.url}>
|
||||
{p.is_new && <Badge color="signal" className="mr-1.5">new</Badge>}
|
||||
{p.host}:{p.port}
|
||||
</td>
|
||||
<td className="py-2.5 uppercase" style={{ color: PROTO_COLORS[p.protocol] ?? "#8A93A6" }}>
|
||||
{p.protocol}
|
||||
</td>
|
||||
<td className="py-2.5 text-fog-muted">
|
||||
{countryFlag(p.country)} {p.country || "—"}
|
||||
</td>
|
||||
<td className="py-2.5 text-fog-muted">{p.exit_ip || "—"}</td>
|
||||
<td className="py-2.5 text-right">
|
||||
<span className="inline-flex items-center gap-2 text-fog-muted">
|
||||
{p.latency_ms}ms <SignalBars latencyMs={p.latency_ms} />
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2.5 text-right text-signal">{fmtSpeed(p.speed_mbps)}</td>
|
||||
<td className="max-w-[140px] truncate py-2.5 text-fog-muted" title={p.source}>
|
||||
{p.source || "—"}
|
||||
</td>
|
||||
<td className="py-2.5 text-right text-fog-faint">{fmtAgo(p.first_seen)}</td>
|
||||
<td className="py-2.5 pr-4 text-right">
|
||||
<Button variant="ghost" size="icon" onClick={() => copyOne(p.url)} aria-label="Copy config">
|
||||
<Copy size={14} />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{pages > 1 && (
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<Button variant="outline" size="sm" disabled={page === 0} onClick={() => setPage((p) => p - 1)}>
|
||||
Prev
|
||||
</Button>
|
||||
<span className="num text-xs text-fog-muted">
|
||||
{page + 1} / {pages}
|
||||
</span>
|
||||
<Button variant="outline" size="sm" disabled={page >= pages - 1} onClick={() => setPage((p) => p + 1)}>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import * as React from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Plus, Trash2, Check, X } from "lucide-react";
|
||||
import { api } from "@/lib/api";
|
||||
import { Badge, Button, Card, CardTitle, Input, Label, Spinner, Toggle } from "@/components/ui";
|
||||
import { useToast } from "@/components/Toast";
|
||||
import { fmtAgo, fmtInt } from "@/lib/format";
|
||||
import type { Settings as SettingsMap, Source } from "@/lib/types";
|
||||
|
||||
export default function Settings() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<div className="eyebrow mb-1">Configuration</div>
|
||||
<h1 className="font-display text-2xl font-bold tracking-tight">Settings</h1>
|
||||
</div>
|
||||
<SourcesPanel />
|
||||
<CheckParamsPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Sources --------------------------------------------------------------
|
||||
|
||||
function SourcesPanel() {
|
||||
const toast = useToast();
|
||||
const qc = useQueryClient();
|
||||
const list = useQuery({ queryKey: ["sources"], queryFn: api.sources });
|
||||
const [name, setName] = React.useState("");
|
||||
const [url, setUrl] = React.useState("");
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: () => api.createSource({ name: name.trim() || url.trim(), url: url.trim(), enabled: true }),
|
||||
onSuccess: () => {
|
||||
toast("success", "Source added");
|
||||
setName("");
|
||||
setUrl("");
|
||||
qc.invalidateQueries({ queryKey: ["sources"] });
|
||||
},
|
||||
onError: (e: Error) => toast("error", e.message),
|
||||
});
|
||||
const toggle = useMutation({
|
||||
mutationFn: (s: Source) => api.updateSource(s.id, { name: s.name, url: s.url, enabled: !s.enabled }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["sources"] }),
|
||||
onError: (e: Error) => toast("error", e.message),
|
||||
});
|
||||
const del = useMutation({
|
||||
mutationFn: (id: number) => api.deleteSource(id),
|
||||
onSuccess: () => {
|
||||
toast("success", "Source removed");
|
||||
qc.invalidateQueries({ queryKey: ["sources"] });
|
||||
},
|
||||
onError: (e: Error) => toast("error", e.message),
|
||||
});
|
||||
|
||||
const items = list.data?.items ?? [];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardTitle eyebrow="Where proxies come from" title="Sources" right={<span className="num text-xs text-fog-faint">{items.length}</span>} />
|
||||
|
||||
<form
|
||||
className="mb-4 flex flex-wrap items-end gap-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (url.trim()) create.mutate();
|
||||
}}
|
||||
>
|
||||
<div className="w-40">
|
||||
<Label>Name</Label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="optional" />
|
||||
</div>
|
||||
<div className="min-w-[280px] flex-1">
|
||||
<Label>List URL</Label>
|
||||
<Input value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://…/all_configs.txt" />
|
||||
</div>
|
||||
<Button type="submit" variant="primary" size="sm" disabled={create.isPending || !url.trim()}>
|
||||
<Plus size={14} /> Add
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{list.isLoading ? (
|
||||
<Spinner />
|
||||
) : items.length === 0 ? (
|
||||
<p className="py-6 text-center font-mono text-xs text-fog-faint">No sources — add at least one to fetch proxies.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="eyebrow border-b border-ink-700 text-fog-faint">
|
||||
<th className="w-16 py-2 font-normal">On</th>
|
||||
<th className="py-2 font-normal">Name / URL</th>
|
||||
<th className="py-2 text-right font-normal">Last lines</th>
|
||||
<th className="py-2 text-right font-normal">Fetched</th>
|
||||
<th className="py-2 pr-2 text-right font-normal">Status</th>
|
||||
<th className="w-10"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-xs">
|
||||
{items.map((s) => (
|
||||
<tr key={s.id} className="border-b border-ink-800/60">
|
||||
<td className="py-2.5">
|
||||
<Toggle checked={s.enabled} onChange={() => toggle.mutate(s)} label="Enabled" />
|
||||
</td>
|
||||
<td className="max-w-[360px] py-2.5">
|
||||
<div className="truncate font-sans text-fog">{s.name}</div>
|
||||
<div className="truncate font-mono text-[11px] text-fog-faint" title={s.url}>
|
||||
{s.url}
|
||||
</div>
|
||||
</td>
|
||||
<td className="num py-2.5 text-right text-fog-muted">{fmtInt(s.last_line_count)}</td>
|
||||
<td className="py-2.5 text-right font-mono text-fog-faint">{fmtAgo(s.last_fetched_at)}</td>
|
||||
<td className="py-2.5 pr-2 text-right">
|
||||
{s.last_error ? (
|
||||
<Badge color="rose">
|
||||
<X size={11} className="mr-1" /> error
|
||||
</Badge>
|
||||
) : s.last_fetched_at ? (
|
||||
<Badge color="signal">
|
||||
<Check size={11} className="mr-1" /> ok
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge color="muted">idle</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2.5 text-right">
|
||||
<Button variant="ghost" size="icon" onClick={() => del.mutate(s.id)} aria-label="Delete source">
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Check parameters -----------------------------------------------------
|
||||
|
||||
const KEYS = {
|
||||
interval: "check_interval_hours",
|
||||
workers: "workers",
|
||||
timeout: "timeout_sec",
|
||||
maxLatency: "max_latency_ms",
|
||||
minSpeed: "min_speed_mbps",
|
||||
speedEnabled: "speedtest_enabled",
|
||||
speedUrl: "speedtest_url",
|
||||
geoUrl: "geoip_db_url",
|
||||
auto: "auto_enabled",
|
||||
tgToken: "telegram_bot_token",
|
||||
tgChat: "telegram_chat_id",
|
||||
tgStart: "telegram_notify_start",
|
||||
tgFinish: "telegram_notify_finish",
|
||||
};
|
||||
|
||||
function CheckParamsPanel() {
|
||||
const toast = useToast();
|
||||
const qc = useQueryClient();
|
||||
const q = useQuery({ queryKey: ["settings"], queryFn: api.settings });
|
||||
const [form, setForm] = React.useState<SettingsMap>({});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (q.data) setForm(q.data);
|
||||
}, [q.data]);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (body: SettingsMap) => api.updateSettings(body),
|
||||
onSuccess: () => {
|
||||
toast("success", "Settings saved");
|
||||
qc.invalidateQueries({ queryKey: ["settings"] });
|
||||
},
|
||||
onError: (e: Error) => toast("error", e.message),
|
||||
});
|
||||
|
||||
function set(key: string, val: string) {
|
||||
setForm((f) => ({ ...f, [key]: val }));
|
||||
}
|
||||
const bool = (key: string) => form[key] === "true";
|
||||
|
||||
if (q.isLoading) return <Spinner />;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardTitle eyebrow="Cadence & pipeline" title="Check parameters" />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field label="Interval (hours)">
|
||||
<Input type="number" value={form[KEYS.interval] ?? ""} onChange={(e) => set(KEYS.interval, e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Workers">
|
||||
<Input type="number" value={form[KEYS.workers] ?? ""} onChange={(e) => set(KEYS.workers, e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Timeout (sec)">
|
||||
<Input type="number" value={form[KEYS.timeout] ?? ""} onChange={(e) => set(KEYS.timeout, e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Max latency (ms)">
|
||||
<Input type="number" value={form[KEYS.maxLatency] ?? ""} onChange={(e) => set(KEYS.maxLatency, e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Min speed (Mbps)">
|
||||
<Input type="number" value={form[KEYS.minSpeed] ?? ""} onChange={(e) => set(KEYS.minSpeed, e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Auto scheduling">
|
||||
<div className="flex h-9 items-center gap-2">
|
||||
<Toggle checked={bool(KEYS.auto)} onChange={(v) => set(KEYS.auto, String(v))} label="Auto" />
|
||||
<span className="text-sm text-fog-muted">{bool(KEYS.auto) ? "on" : "manual only"}</span>
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="mt-4 space-y-4">
|
||||
<Field label="Speed test">
|
||||
<div className="flex items-center gap-3">
|
||||
<Toggle checked={bool(KEYS.speedEnabled)} onChange={(v) => set(KEYS.speedEnabled, String(v))} label="Speedtest" />
|
||||
<Input
|
||||
value={form[KEYS.speedUrl] ?? ""}
|
||||
onChange={(e) => set(KEYS.speedUrl, e.target.value)}
|
||||
placeholder="https://…/__down?bytes=10000000"
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="GeoIP database URL (mmdb)">
|
||||
<Input value={form[KEYS.geoUrl] ?? ""} onChange={(e) => set(KEYS.geoUrl, e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardTitle eyebrow="Alerts" title="Telegram notifications" />
|
||||
<div className="space-y-4">
|
||||
<Field label="Bot token">
|
||||
<Input value={form[KEYS.tgToken] ?? ""} onChange={(e) => set(KEYS.tgToken, e.target.value)} placeholder="123:ABC…" />
|
||||
</Field>
|
||||
<Field label="Chat ID">
|
||||
<Input value={form[KEYS.tgChat] ?? ""} onChange={(e) => set(KEYS.tgChat, e.target.value)} placeholder="-1001234567890" />
|
||||
</Field>
|
||||
<div className="flex items-center gap-6">
|
||||
<label className="flex items-center gap-2 text-sm text-fog-muted">
|
||||
<Toggle checked={bool(KEYS.tgStart)} onChange={(v) => set(KEYS.tgStart, String(v))} label="Notify start" />
|
||||
on check start
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-fog-muted">
|
||||
<Toggle checked={bool(KEYS.tgFinish)} onChange={(v) => set(KEYS.tgFinish, String(v))} label="Notify finish" />
|
||||
on check finish
|
||||
</label>
|
||||
</div>
|
||||
<p className="font-mono text-[11px] leading-relaxed text-fog-faint">
|
||||
Exit-IP echo endpoints are hardcoded with fallbacks. The finish message includes the session summary.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end border-t border-ink-700 pt-4">
|
||||
<Button variant="primary" onClick={() => save.mutate(form)} disabled={save.isPending}>
|
||||
{save.isPending ? "Saving…" : "Save settings"}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<Label>{label}</Label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
import * as React from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Copy, Link2, Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import { api } from "@/lib/api";
|
||||
import { Badge, Button, EmptyState, Input, Label, Modal, Select, Spinner, Toggle } from "@/components/ui";
|
||||
import { useToast } from "@/components/Toast";
|
||||
import { cn, fmtAgo, fmtInt } from "@/lib/format";
|
||||
import type { Facets, Subscription } from "@/lib/types";
|
||||
|
||||
const FORMATS = ["plain", "base64", "clash", "singbox"];
|
||||
const PROTOCOLS = ["vless", "vmess", "trojan", "ss"];
|
||||
const SORT_FIELDS = ["speed", "latency", "country", "protocol", "source"];
|
||||
|
||||
function subUrl(token: string) {
|
||||
return `${window.location.origin}/sub/${token}`;
|
||||
}
|
||||
|
||||
export default function Subscriptions() {
|
||||
const toast = useToast();
|
||||
const qc = useQueryClient();
|
||||
const list = useQuery({ queryKey: ["subscriptions"], queryFn: api.subscriptions });
|
||||
const facets = useQuery({ queryKey: ["facets"], queryFn: api.proxyFacets });
|
||||
|
||||
const [editing, setEditing] = React.useState<Subscription | null>(null);
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
const del = useMutation({
|
||||
mutationFn: (id: number) => api.deleteSubscription(id),
|
||||
onSuccess: () => {
|
||||
toast("success", "Subscription deleted");
|
||||
qc.invalidateQueries({ queryKey: ["subscriptions"] });
|
||||
},
|
||||
onError: (e: Error) => toast("error", e.message),
|
||||
});
|
||||
|
||||
function openCreate() {
|
||||
setEditing(null);
|
||||
setOpen(true);
|
||||
}
|
||||
function openEdit(s: Subscription) {
|
||||
setEditing(s);
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
const items = list.data?.items ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-end justify-between">
|
||||
<div>
|
||||
<div className="eyebrow mb-1">Dynamic client feeds</div>
|
||||
<h1 className="font-display text-2xl font-bold tracking-tight">Subscriptions</h1>
|
||||
</div>
|
||||
<Button variant="primary" size="sm" onClick={openCreate}>
|
||||
<Plus size={15} /> New subscription
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{list.isLoading ? (
|
||||
<Spinner />
|
||||
) : items.length === 0 ? (
|
||||
<EmptyState title="No subscriptions yet" hint="Create one to hand clients a filtered, always-fresh feed." />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||
{items.map((s) => (
|
||||
<SubCard key={s.id} sub={s} onEdit={() => openEdit(s)} onDelete={() => del.mutate(s.id)} toast={toast} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SubscriptionForm
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
editing={editing}
|
||||
facets={facets.data}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SubCard({
|
||||
sub,
|
||||
onEdit,
|
||||
onDelete,
|
||||
toast,
|
||||
}: {
|
||||
sub: Subscription;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
toast: (k: "success" | "error" | "info", m: string) => void;
|
||||
}) {
|
||||
const url = subUrl(sub.token);
|
||||
const expired = sub.expires_at ? new Date(sub.expires_at) < new Date() : false;
|
||||
const live = sub.enabled && !expired;
|
||||
const filters: string[] = [];
|
||||
if (sub.filter_protocols.length) filters.push(sub.filter_protocols.join("/"));
|
||||
if (sub.filter_countries.length) filters.push(sub.filter_countries.join(","));
|
||||
if (sub.filter_sources.length) filters.push(`${sub.filter_sources.length} src`);
|
||||
if (sub.max_latency_ms) filters.push(`≤${sub.max_latency_ms}ms`);
|
||||
if (sub.min_speed_mbps) filters.push(`≥${sub.min_speed_mbps}Mbps`);
|
||||
if (sub.limit_n) filters.push(`top ${sub.limit_n}`);
|
||||
if (sub.unique_ips) filters.push(`unique/${sub.unique_ips_metric}`);
|
||||
|
||||
return (
|
||||
<div className="panel p-5">
|
||||
<div className="mb-3 flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="truncate font-display font-semibold text-fog">{sub.name || "Untitled"}</h3>
|
||||
<Badge color={live ? "signal" : "muted"}>{expired ? "expired" : sub.enabled ? "live" : "off"}</Badge>
|
||||
<Badge color="peri">{sub.format}</Badge>
|
||||
</div>
|
||||
<div className="mt-1 font-mono text-[11px] text-fog-faint">
|
||||
{fmtInt(sub.request_count)} hits · last {fmtAgo(sub.last_requested_at)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-1">
|
||||
<Button variant="ghost" size="icon" onClick={onEdit} aria-label="Edit">
|
||||
<Pencil size={15} />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={onDelete} aria-label="Delete">
|
||||
<Trash2 size={15} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3 flex items-center gap-2 rounded-lg border border-ink-700 bg-ink-900 px-3 py-2">
|
||||
<Link2 size={14} className="shrink-0 text-fog-faint" />
|
||||
<span className="truncate font-mono text-xs text-fog-muted">{url}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="ml-auto shrink-0"
|
||||
onClick={() => navigator.clipboard.writeText(url).then(() => toast("info", "Link copied"))}
|
||||
aria-label="Copy link"
|
||||
>
|
||||
<Copy size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{filters.length === 0 ? (
|
||||
<span className="font-mono text-[11px] text-fog-faint">no filters · all working proxies</span>
|
||||
) : (
|
||||
filters.map((f, i) => (
|
||||
<span key={i} className="rounded-md bg-ink-800 px-2 py-0.5 font-mono text-[11px] text-fog-muted">
|
||||
{f}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SubscriptionForm({
|
||||
open,
|
||||
onClose,
|
||||
editing,
|
||||
facets,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
editing: Subscription | null;
|
||||
facets: Facets | undefined;
|
||||
}) {
|
||||
const toast = useToast();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const [name, setName] = React.useState("");
|
||||
const [format, setFormat] = React.useState("plain");
|
||||
const [enabled, setEnabled] = React.useState(true);
|
||||
const [protocols, setProtocols] = React.useState<string[]>([]);
|
||||
const [countries, setCountries] = React.useState("");
|
||||
const [sources, setSources] = React.useState<string[]>([]);
|
||||
const [maxLatency, setMaxLatency] = React.useState("");
|
||||
const [minSpeed, setMinSpeed] = React.useState("");
|
||||
const [limitN, setLimitN] = React.useState("");
|
||||
const [uniqueIPs, setUniqueIPs] = React.useState(false);
|
||||
const [metric, setMetric] = React.useState("speed");
|
||||
const [sortBy, setSortBy] = React.useState<string[]>([]);
|
||||
const [expires, setExpires] = React.useState("");
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
if (editing) {
|
||||
setName(editing.name);
|
||||
setFormat(editing.format);
|
||||
setEnabled(editing.enabled);
|
||||
setProtocols(editing.filter_protocols);
|
||||
setCountries(editing.filter_countries.join(", "));
|
||||
setSources(editing.filter_sources);
|
||||
setMaxLatency(editing.max_latency_ms ? String(editing.max_latency_ms) : "");
|
||||
setMinSpeed(editing.min_speed_mbps ? String(editing.min_speed_mbps) : "");
|
||||
setLimitN(editing.limit_n ? String(editing.limit_n) : "");
|
||||
setUniqueIPs(editing.unique_ips);
|
||||
setMetric(editing.unique_ips_metric);
|
||||
setSortBy(editing.sort_by);
|
||||
setExpires(editing.expires_at ? editing.expires_at.slice(0, 16) : "");
|
||||
} else {
|
||||
setName("");
|
||||
setFormat("plain");
|
||||
setEnabled(true);
|
||||
setProtocols([]);
|
||||
setCountries("");
|
||||
setSources([]);
|
||||
setMaxLatency("");
|
||||
setMinSpeed("");
|
||||
setLimitN("");
|
||||
setUniqueIPs(false);
|
||||
setMetric("speed");
|
||||
setSortBy([]);
|
||||
setExpires("");
|
||||
}
|
||||
}, [open, editing]);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (body: Partial<Subscription>) =>
|
||||
editing ? api.updateSubscription(editing.id, body) : api.createSubscription(body),
|
||||
onSuccess: () => {
|
||||
toast("success", editing ? "Subscription updated" : "Subscription created");
|
||||
qc.invalidateQueries({ queryKey: ["subscriptions"] });
|
||||
onClose();
|
||||
},
|
||||
onError: (e: Error) => toast("error", e.message),
|
||||
});
|
||||
|
||||
function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
save.mutate({
|
||||
name,
|
||||
format,
|
||||
enabled,
|
||||
filter_protocols: protocols,
|
||||
filter_countries: countries
|
||||
.split(",")
|
||||
.map((c) => c.trim().toUpperCase())
|
||||
.filter(Boolean),
|
||||
filter_sources: sources,
|
||||
max_latency_ms: maxLatency ? Number(maxLatency) : null,
|
||||
min_speed_mbps: minSpeed ? Number(minSpeed) : null,
|
||||
limit_n: limitN ? Number(limitN) : null,
|
||||
unique_ips: uniqueIPs,
|
||||
unique_ips_metric: metric,
|
||||
sort_by: sortBy,
|
||||
expires_at: expires ? new Date(expires).toISOString() : null,
|
||||
});
|
||||
}
|
||||
|
||||
function toggleIn(list: string[], setList: (v: string[]) => void, val: string) {
|
||||
setList(list.includes(val) ? list.filter((x) => x !== val) : [...list, val]);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title={editing ? "Edit subscription" : "New subscription"} wide>
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>Name</Label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="Mobile — fast RU" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Format</Label>
|
||||
<Select value={format} onChange={(e) => setFormat(e.target.value)}>
|
||||
{FORMATS.map((f) => (
|
||||
<option key={f} value={f}>
|
||||
{f}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Protocols</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{PROTOCOLS.map((p) => (
|
||||
<Chip key={p} active={protocols.includes(p)} onClick={() => toggleIn(protocols, setProtocols, p)}>
|
||||
{p}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>Countries (comma ISO)</Label>
|
||||
<Input value={countries} onChange={(e) => setCountries(e.target.value)} placeholder="US, DE, NL" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Sources</Label>
|
||||
<div className="flex max-h-[76px] flex-wrap gap-1.5 overflow-y-auto">
|
||||
{(facets?.sources ?? []).length === 0 && (
|
||||
<span className="font-mono text-[11px] text-fog-faint">no sources</span>
|
||||
)}
|
||||
{(facets?.sources ?? []).map((s) => (
|
||||
<Chip key={s} active={sources.includes(s)} onClick={() => toggleIn(sources, setSources, s)}>
|
||||
{s}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<Label>Max latency (ms)</Label>
|
||||
<Input type="number" value={maxLatency} onChange={(e) => setMaxLatency(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Min speed (Mbps)</Label>
|
||||
<Input type="number" value={minSpeed} onChange={(e) => setMinSpeed(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Limit (top N)</Label>
|
||||
<Input type="number" value={limitN} onChange={(e) => setLimitN(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Sort by (order matters)</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{SORT_FIELDS.map((f) => {
|
||||
const asc = sortBy.includes(`${f}:asc`);
|
||||
const desc = sortBy.includes(`${f}:desc`);
|
||||
const active = asc || desc;
|
||||
return (
|
||||
<Chip
|
||||
key={f}
|
||||
active={active}
|
||||
onClick={() => {
|
||||
// cycle: off -> desc -> asc -> off
|
||||
setSortBy((prev) => {
|
||||
const without = prev.filter((x) => !x.startsWith(`${f}:`));
|
||||
if (desc) return [...without, `${f}:asc`];
|
||||
if (asc) return without;
|
||||
return [...without, `${f}:desc`];
|
||||
});
|
||||
}}
|
||||
>
|
||||
{f} {desc ? "↓" : asc ? "↑" : ""}
|
||||
</Chip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="flex items-center gap-3 rounded-lg border border-ink-700 bg-ink-900 px-3 py-2">
|
||||
<Toggle checked={uniqueIPs} onChange={setUniqueIPs} label="Unique IPs" />
|
||||
<span className="text-sm text-fog-muted">unique_ips</span>
|
||||
{uniqueIPs && (
|
||||
<Select value={metric} onChange={(e) => setMetric(e.target.value)} className="h-7 w-24 text-xs">
|
||||
<option value="speed">speed</option>
|
||||
<option value="latency">latency</option>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label>Expires at</Label>
|
||||
<Input type="datetime-local" value={expires} onChange={(e) => setExpires(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-t border-ink-700 pt-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Toggle checked={enabled} onChange={setEnabled} label="Enabled" />
|
||||
<span className="text-sm text-fog-muted">Enabled</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" disabled={save.isPending}>
|
||||
{save.isPending ? "Saving…" : editing ? "Save changes" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function Chip({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"rounded-md border px-2.5 py-1 font-mono text-xs transition-colors",
|
||||
active
|
||||
? "border-signal/40 bg-signal/15 text-signal"
|
||||
: "border-ink-600 bg-ink-800 text-fog-muted hover:text-fog",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// Deep ink-blue console — deliberately not pure black.
|
||||
ink: {
|
||||
950: "#0B0F17",
|
||||
900: "#0F1521",
|
||||
850: "#131926",
|
||||
800: "#171F2E",
|
||||
700: "#1E2635",
|
||||
600: "#28324a",
|
||||
},
|
||||
fog: {
|
||||
DEFAULT: "#E6EAF2",
|
||||
muted: "#8A93A6",
|
||||
faint: "#5A6478",
|
||||
},
|
||||
// Telemetry accents.
|
||||
signal: "#5EE6C4", // alive / live
|
||||
rose: "#F0708A", // dead / invalid
|
||||
peri: "#7C8CF8", // neutral data series
|
||||
amber: "#F5B855", // warning / pending
|
||||
},
|
||||
fontFamily: {
|
||||
display: ['"Space Grotesk"', "system-ui", "sans-serif"],
|
||||
sans: ['"Inter"', "system-ui", "sans-serif"],
|
||||
mono: ['"JetBrains Mono"', "ui-monospace", "monospace"],
|
||||
},
|
||||
boxShadow: {
|
||||
panel: "0 1px 0 0 rgba(255,255,255,0.03) inset, 0 8px 24px -12px rgba(0,0,0,0.6)",
|
||||
glow: "0 0 0 1px rgba(94,230,196,0.25), 0 0 24px -4px rgba(94,230,196,0.35)",
|
||||
},
|
||||
keyframes: {
|
||||
sweep: {
|
||||
"0%": { transform: "translateX(-100%)" },
|
||||
"100%": { transform: "translateX(300%)" },
|
||||
},
|
||||
pulse2: {
|
||||
"0%,100%": { opacity: "1" },
|
||||
"50%": { opacity: "0.35" },
|
||||
},
|
||||
"fade-up": {
|
||||
"0%": { opacity: "0", transform: "translateY(6px)" },
|
||||
"100%": { opacity: "1", transform: "translateY(0)" },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
sweep: "sweep 1.6s ease-in-out infinite",
|
||||
pulse2: "pulse2 1.8s ease-in-out infinite",
|
||||
"fade-up": "fade-up 0.35s ease-out both",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2021",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2021", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": { "@/*": ["src/*"] }
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import path from "node:path";
|
||||
|
||||
// The dev server proxies /api and /sub to the Go API so the SPA runs on the
|
||||
// same origin as production (served by nginx). Override the target with
|
||||
// VITE_API_TARGET when the API is not on localhost:8080.
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: { "@": path.resolve(__dirname, "src") },
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": { target: process.env.VITE_API_TARGET || "http://localhost:8080", changeOrigin: true },
|
||||
"/sub": { target: process.env.VITE_API_TARGET || "http://localhost:8080", changeOrigin: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
module git.qomar.pw/omar/zhguchiy_perchik
|
||||
|
||||
go 1.26.2
|
||||
|
||||
require (
|
||||
github.com/gofrs/uuid v4.4.0+incompatible
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/oschwald/maxminddb-golang v1.13.1
|
||||
github.com/refraction-networking/utls v1.8.2
|
||||
golang.org/x/crypto v0.52.0
|
||||
golang.org/x/net v0.55.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/andybalholm/brotli v1.2.1 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.10.0 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/klauspost/compress v1.18.6 // indirect
|
||||
github.com/stretchr/testify v1.11.1 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
@@ -0,0 +1,46 @@
|
||||
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
|
||||
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA=
|
||||
github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
|
||||
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
|
||||
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE=
|
||||
github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
|
||||
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,101 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const sessionCookie = "zp_session"
|
||||
const sessionTTL = 7 * 24 * time.Hour
|
||||
|
||||
// signToken produces "<expiryUnix>.<hexHMAC>" signed with the session secret.
|
||||
func (s *Server) signToken(expiry time.Time) string {
|
||||
payload := strconv.FormatInt(expiry.Unix(), 10)
|
||||
mac := hmac.New(sha256.New, []byte(s.cfg.SessionSecret))
|
||||
mac.Write([]byte(payload))
|
||||
return payload + "." + hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
// verifyToken checks the HMAC and expiry.
|
||||
func (s *Server) verifyToken(token string) bool {
|
||||
parts := strings.SplitN(token, ".", 2)
|
||||
if len(parts) != 2 {
|
||||
return false
|
||||
}
|
||||
exp, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
if err != nil || time.Now().Unix() > exp {
|
||||
return false
|
||||
}
|
||||
mac := hmac.New(sha256.New, []byte(s.cfg.SessionSecret))
|
||||
mac.Write([]byte(parts[0]))
|
||||
want := hex.EncodeToString(mac.Sum(nil))
|
||||
return subtle.ConstantTimeCompare([]byte(want), []byte(parts[1])) == 1
|
||||
}
|
||||
|
||||
// tokenFromRequest reads the session token from the cookie or bearer header.
|
||||
func tokenFromRequest(r *http.Request) string {
|
||||
if c, err := r.Cookie(sessionCookie); err == nil && c.Value != "" {
|
||||
return c.Value
|
||||
}
|
||||
if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
|
||||
return strings.TrimPrefix(h, "Bearer ")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// auth wraps a handler requiring a valid session.
|
||||
func (s *Server) auth(next http.HandlerFunc) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.verifyToken(tokenFromRequest(r)) {
|
||||
writeErr(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
type loginReq struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
var req loginReq
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "bad request")
|
||||
return
|
||||
}
|
||||
userOK := subtle.ConstantTimeCompare([]byte(req.Username), []byte(s.cfg.AdminUser)) == 1
|
||||
passOK := subtle.ConstantTimeCompare([]byte(req.Password), []byte(s.cfg.AdminPassword)) == 1
|
||||
if !userOK || !passOK {
|
||||
writeErr(w, http.StatusUnauthorized, "invalid credentials")
|
||||
return
|
||||
}
|
||||
token := s.signToken(time.Now().Add(sessionTTL))
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookie,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: int(sessionTTL.Seconds()),
|
||||
})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"token": token, "user": s.cfg.AdminUser})
|
||||
}
|
||||
|
||||
func (s *Server) handleLogout(w http.ResponseWriter, _ *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookie, Value: "", Path: "/", HttpOnly: true, MaxAge: -1,
|
||||
})
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleMe(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"user": s.cfg.AdminUser})
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package api
|
||||
|
||||
import "net/http"
|
||||
|
||||
// handleCheckerStatus reports the live check state (running + current session).
|
||||
func (s *Server) handleCheckerStatus(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
running, _ := s.db.GetRunningSession(ctx)
|
||||
current, _ := s.db.GetCurrentSession(ctx)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"busy": running != nil,
|
||||
"running": running,
|
||||
"current": current,
|
||||
})
|
||||
}
|
||||
|
||||
// handleCheckerRun enqueues a manual full cycle. Rejected while one is running
|
||||
// (the worker enforces the single-operation lock; this is a friendly guard).
|
||||
func (s *Server) handleCheckerRun(w http.ResponseWriter, r *http.Request) {
|
||||
if running, _ := s.db.GetRunningSession(r.Context()); running != nil {
|
||||
writeErr(w, http.StatusConflict, "a check is already running")
|
||||
return
|
||||
}
|
||||
if err := s.db.SetSetting(r.Context(), "manual_run_requested", "true"); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, map[string]string{"status": "queued"})
|
||||
}
|
||||
|
||||
// handleCheckerStop requests cancellation of the running session.
|
||||
func (s *Server) handleCheckerStop(w http.ResponseWriter, r *http.Request) {
|
||||
running, err := s.db.GetRunningSession(r.Context())
|
||||
if err != nil || running == nil {
|
||||
writeErr(w, http.StatusBadRequest, "no running check")
|
||||
return
|
||||
}
|
||||
if err := s.db.RequestCancel(r.Context(), running.ID); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "stopping"})
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package api
|
||||
|
||||
import "net/http"
|
||||
|
||||
// handleDashboard assembles the full stats bundle for the Dashboard tab.
|
||||
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
resp := map[string]any{}
|
||||
|
||||
// Live progress: the running session, if any.
|
||||
if running, err := s.db.GetRunningSession(ctx); err == nil {
|
||||
resp["running"] = running
|
||||
}
|
||||
// Recent sessions power the trend charts (kept forever).
|
||||
if recent, err := s.db.RecentSessions(ctx, 90); err == nil {
|
||||
resp["sessions"] = recent
|
||||
}
|
||||
|
||||
cur, err := s.db.GetCurrentSession(ctx)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
resp["current"] = cur
|
||||
if cur == nil {
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
return
|
||||
}
|
||||
|
||||
valid, invalid := cur.Valid, cur.Invalid
|
||||
totalChecked := valid + invalid
|
||||
validPct := 0.0
|
||||
if totalChecked > 0 {
|
||||
validPct = float64(valid) / float64(totalChecked) * 100
|
||||
}
|
||||
resp["summary"] = map[string]any{
|
||||
"valid": valid,
|
||||
"invalid": invalid,
|
||||
"total_checked": totalChecked,
|
||||
"valid_pct": validPct,
|
||||
"working_total": valid,
|
||||
"collapsed": cur.Collapsed,
|
||||
"raw_lines": cur.TotalRawLines,
|
||||
"unique": cur.UniqueCandidates,
|
||||
"duration_ms": cur.DurationMs,
|
||||
"finished_at": cur.FinishedAt,
|
||||
}
|
||||
|
||||
if v, err := s.db.ProtocolStats(ctx, cur.ID); err == nil {
|
||||
resp["protocols"] = v
|
||||
}
|
||||
if v, err := s.db.CountryStats(ctx, cur.ID, 20); err == nil {
|
||||
resp["countries"] = v
|
||||
}
|
||||
if v, err := s.db.SourceStats(ctx, cur.ID); err == nil {
|
||||
resp["sources"] = v
|
||||
}
|
||||
if v, err := s.db.LatencyBuckets(ctx, cur.ID); err == nil {
|
||||
resp["latency_buckets"] = v
|
||||
}
|
||||
if v, err := s.db.SpeedBuckets(ctx, cur.ID); err == nil {
|
||||
resp["speed_buckets"] = v
|
||||
}
|
||||
if avg, median, err := s.db.LatencyAvgMedian(ctx, cur.ID); err == nil {
|
||||
resp["latency_avg"] = avg
|
||||
resp["latency_median"] = median
|
||||
}
|
||||
if avg, median, err := s.db.SpeedAvgMedian(ctx, cur.ID); err == nil {
|
||||
resp["speed_avg"] = avg
|
||||
resp["speed_median"] = median
|
||||
}
|
||||
if v, err := s.db.TopUptime(ctx, cur.ID, 20); err == nil {
|
||||
resp["uptime"] = v
|
||||
}
|
||||
if prev, err := s.db.PrevCompletedSessionID(ctx, cur.ID); err == nil {
|
||||
if added, dropped, err := s.db.DynamicsCounts(ctx, prev, cur.ID); err == nil {
|
||||
resp["dynamics"] = map[string]any{"added": added, "dropped": dropped}
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.qomar.pw/omar/zhguchiy_perchik/internal/db"
|
||||
"git.qomar.pw/omar/zhguchiy_perchik/internal/subs"
|
||||
)
|
||||
|
||||
func (s *Server) handleListProxies(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
cur, err := s.db.GetCurrentSession(ctx)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if cur == nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": []any{}, "total": 0, "session": nil})
|
||||
return
|
||||
}
|
||||
|
||||
f := parseProxyFilter(r)
|
||||
q := r.URL.Query()
|
||||
uniqueIPs := q.Get("unique_ips") == "true"
|
||||
metric := q.Get("metric")
|
||||
|
||||
if uniqueIPs {
|
||||
// Collapse in memory, then paginate the collapsed set.
|
||||
all, err := s.db.ListSessionProxies(ctx, cur.ID, db.ProxyFilter{
|
||||
Protocols: f.Protocols, Countries: f.Countries, Sources: f.Sources,
|
||||
MaxLatencyMs: f.MaxLatencyMs, MinSpeedMbps: f.MinSpeedMbps, Search: f.Search,
|
||||
}, true)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
collapsed := subs.CollapseUniqueIPs(all, metric)
|
||||
total := len(collapsed)
|
||||
items := paginate(collapsed, f.Offset, f.Limit)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items, "total": total, "session": cur})
|
||||
return
|
||||
}
|
||||
|
||||
total, err := s.db.CountSessionProxies(ctx, cur.ID, f)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
items, err := s.db.ListSessionProxies(ctx, cur.ID, f, true)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if items == nil {
|
||||
items = []db.SessionProxy{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items, "total": total, "session": cur})
|
||||
}
|
||||
|
||||
func paginate(items []db.SessionProxy, offset, limit int) []db.SessionProxy {
|
||||
if offset >= len(items) {
|
||||
return []db.SessionProxy{}
|
||||
}
|
||||
items = items[offset:]
|
||||
if limit > 0 && limit < len(items) {
|
||||
items = items[:limit]
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func (s *Server) handleExportProxies(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
cur, err := s.db.GetCurrentSession(ctx)
|
||||
if err != nil || cur == nil {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
return
|
||||
}
|
||||
f := parseProxyFilter(r)
|
||||
f.Limit, f.Offset = 0, 0
|
||||
items, err := s.db.ListSessionProxies(ctx, cur.ID, f, false)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if r.URL.Query().Get("unique_ips") == "true" {
|
||||
items = subs.CollapseUniqueIPs(items, r.URL.Query().Get("metric"))
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, p := range items {
|
||||
b.WriteString(p.CanonicalURL)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="proxies.txt"`)
|
||||
_, _ = w.Write([]byte(b.String()))
|
||||
}
|
||||
|
||||
type idsReq struct {
|
||||
IDs []int64 `json:"ids"`
|
||||
}
|
||||
|
||||
func (s *Server) handleRecheckProxies(w http.ResponseWriter, r *http.Request) {
|
||||
var req idsReq
|
||||
if err := decodeJSON(r, &req); err != nil || len(req.IDs) == 0 {
|
||||
writeErr(w, http.StatusBadRequest, "ids required")
|
||||
return
|
||||
}
|
||||
// Enqueue for the worker: it consumes this under the global check lock.
|
||||
parts := make([]string, len(req.IDs))
|
||||
for i, id := range req.IDs {
|
||||
parts[i] = strconv.FormatInt(id, 10)
|
||||
}
|
||||
if err := s.db.SetSetting(r.Context(), "manual_recheck_ids", strings.Join(parts, ",")); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{"queued": len(req.IDs)})
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteProxies(w http.ResponseWriter, r *http.Request) {
|
||||
var req idsReq
|
||||
if err := decodeJSON(r, &req); err != nil || len(req.IDs) == 0 {
|
||||
writeErr(w, http.StatusBadRequest, "ids required")
|
||||
return
|
||||
}
|
||||
cur, err := s.db.GetCurrentSession(r.Context())
|
||||
if err != nil || cur == nil {
|
||||
writeErr(w, http.StatusBadRequest, "no current session")
|
||||
return
|
||||
}
|
||||
if err := s.db.DeleteSessionProxies(r.Context(), cur.ID, req.IDs); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"deleted": len(req.IDs)})
|
||||
}
|
||||
|
||||
// handleProxyFacets returns the filter option sets for the Proxies tab.
|
||||
func (s *Server) handleProxyFacets(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
facets := map[string]any{
|
||||
"protocols": []string{"vless", "vmess", "trojan", "ss"},
|
||||
"countries": []string{},
|
||||
"sources": []string{},
|
||||
}
|
||||
if cur, err := s.db.GetCurrentSession(ctx); err == nil && cur != nil {
|
||||
if c, err := s.db.DistinctCountries(ctx, cur.ID); err == nil {
|
||||
facets["countries"] = c
|
||||
}
|
||||
}
|
||||
if srcs, err := s.db.ListSources(ctx); err == nil {
|
||||
names := make([]string, 0, len(srcs))
|
||||
for _, sc := range srcs {
|
||||
names = append(names, sc.Name)
|
||||
}
|
||||
facets["sources"] = names
|
||||
}
|
||||
writeJSON(w, http.StatusOK, facets)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.qomar.pw/omar/zhguchiy_perchik/internal/config"
|
||||
)
|
||||
|
||||
// editableKeys is the whitelist of settings the admin may read and write.
|
||||
// Internal control keys (manual_run_requested, manual_recheck_ids) are excluded.
|
||||
var editableKeys = map[string]struct{}{
|
||||
config.KeyCheckIntervalHours: {},
|
||||
config.KeyWorkers: {},
|
||||
config.KeyTimeoutSec: {},
|
||||
config.KeyMaxLatencyMs: {},
|
||||
config.KeyMinSpeedMbps: {},
|
||||
config.KeySpeedTestEnabled: {},
|
||||
config.KeySpeedTestURL: {},
|
||||
config.KeyGeoIPDBURL: {},
|
||||
config.KeyAutoEnabled: {},
|
||||
config.KeyTelegramBotToken: {},
|
||||
config.KeyTelegramChatID: {},
|
||||
config.KeyTelegramNotifyStart: {},
|
||||
config.KeyTelegramNotifyFinish: {},
|
||||
}
|
||||
|
||||
func (s *Server) handleGetSettings(w http.ResponseWriter, r *http.Request) {
|
||||
m, err := s.db.GetSettings(r.Context())
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
out := make(map[string]string, len(editableKeys))
|
||||
for k := range editableKeys {
|
||||
if v, ok := m[k]; ok {
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
var req map[string]string
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "bad request")
|
||||
return
|
||||
}
|
||||
upd := make(map[string]string, len(req))
|
||||
for k, v := range req {
|
||||
if _, ok := editableKeys[k]; ok {
|
||||
upd[k] = v
|
||||
}
|
||||
}
|
||||
if len(upd) == 0 {
|
||||
writeErr(w, http.StatusBadRequest, "no editable keys")
|
||||
return
|
||||
}
|
||||
if err := s.db.SetSettings(r.Context(), upd); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.qomar.pw/omar/zhguchiy_perchik/internal/db"
|
||||
)
|
||||
|
||||
func (s *Server) handleListSources(w http.ResponseWriter, r *http.Request) {
|
||||
srcs, err := s.db.ListSources(r.Context())
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if srcs == nil {
|
||||
srcs = []db.Source{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": srcs})
|
||||
}
|
||||
|
||||
type sourceReq struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateSource(w http.ResponseWriter, r *http.Request) {
|
||||
var req sourceReq
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "bad request")
|
||||
return
|
||||
}
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
req.URL = strings.TrimSpace(req.URL)
|
||||
if req.URL == "" {
|
||||
writeErr(w, http.StatusBadRequest, "url required")
|
||||
return
|
||||
}
|
||||
if req.Name == "" {
|
||||
req.Name = req.URL
|
||||
}
|
||||
enabled := true
|
||||
if req.Enabled != nil {
|
||||
enabled = *req.Enabled
|
||||
}
|
||||
id, err := s.db.CreateSource(r.Context(), req.Name, req.URL, enabled)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"id": id})
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateSource(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := pathID(r)
|
||||
if !ok {
|
||||
writeErr(w, http.StatusBadRequest, "bad id")
|
||||
return
|
||||
}
|
||||
var req sourceReq
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "bad request")
|
||||
return
|
||||
}
|
||||
enabled := true
|
||||
if req.Enabled != nil {
|
||||
enabled = *req.Enabled
|
||||
}
|
||||
if err := s.db.UpdateSource(r.Context(), id, strings.TrimSpace(req.Name), strings.TrimSpace(req.URL), enabled); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteSource(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := pathID(r)
|
||||
if !ok {
|
||||
writeErr(w, http.StatusBadRequest, "bad id")
|
||||
return
|
||||
}
|
||||
if err := s.db.DeleteSource(r.Context(), id); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.qomar.pw/omar/zhguchiy_perchik/internal/db"
|
||||
"git.qomar.pw/omar/zhguchiy_perchik/internal/subs"
|
||||
)
|
||||
|
||||
// handleSub serves a subscription dynamically: every request re-evaluates the
|
||||
// current session's working proxies against the subscription's filters, applies
|
||||
// unique_ips → sort → limit, and renders the chosen format. Disabled or expired
|
||||
// subscriptions return 404.
|
||||
func (s *Server) handleSub(w http.ResponseWriter, r *http.Request) {
|
||||
token := r.PathValue("token")
|
||||
sub, err := s.db.GetSubscriptionByToken(r.Context(), token)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if sub == nil || !sub.Enabled {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if sub.ExpiresAt != nil && time.Now().After(*sub.ExpiresAt) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
_ = s.db.TouchSubscription(r.Context(), sub.ID)
|
||||
|
||||
cur, err := s.db.GetCurrentSession(r.Context())
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var items []db.SessionProxy
|
||||
if cur != nil {
|
||||
f := db.ProxyFilter{
|
||||
Protocols: sub.FilterProtocols,
|
||||
Countries: sub.FilterCountries,
|
||||
Sources: sub.FilterSources,
|
||||
MaxLatencyMs: sub.MaxLatencyMs,
|
||||
MinSpeedMbps: sub.MinSpeedMbps,
|
||||
}
|
||||
items, err = s.db.ListSessionProxies(r.Context(), cur.ID, f, false)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
limit := 0
|
||||
if sub.LimitN != nil {
|
||||
limit = *sub.LimitN
|
||||
}
|
||||
body, contentType := subs.Build(items, subs.Options{
|
||||
Format: sub.Format,
|
||||
UniqueIPs: sub.UniqueIPs,
|
||||
UniqueIPsMetric: sub.UniqueIPsMetric,
|
||||
SortBy: sub.SortBy,
|
||||
Limit: limit,
|
||||
})
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write([]byte(body))
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.qomar.pw/omar/zhguchiy_perchik/internal/db"
|
||||
)
|
||||
|
||||
func (s *Server) handleListSubscriptions(w http.ResponseWriter, r *http.Request) {
|
||||
subs, err := s.db.ListSubscriptions(r.Context())
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if subs == nil {
|
||||
subs = []db.Subscription{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": subs})
|
||||
}
|
||||
|
||||
type subReq struct {
|
||||
Name string `json:"name"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Format string `json:"format"`
|
||||
FilterProtocols []string `json:"filter_protocols"`
|
||||
FilterCountries []string `json:"filter_countries"`
|
||||
FilterSources []string `json:"filter_sources"`
|
||||
MaxLatencyMs *int `json:"max_latency_ms"`
|
||||
MinSpeedMbps *float64 `json:"min_speed_mbps"`
|
||||
LimitN *int `json:"limit_n"`
|
||||
UniqueIPs *bool `json:"unique_ips"`
|
||||
UniqueIPsMetric string `json:"unique_ips_metric"`
|
||||
SortBy []string `json:"sort_by"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
var validFormats = map[string]struct{}{"plain": {}, "base64": {}, "clash": {}, "singbox": {}}
|
||||
|
||||
func (r subReq) apply(s *db.Subscription) {
|
||||
s.Name = r.Name
|
||||
if r.Enabled != nil {
|
||||
s.Enabled = *r.Enabled
|
||||
}
|
||||
s.Format = r.Format
|
||||
if _, ok := validFormats[s.Format]; !ok {
|
||||
s.Format = "plain"
|
||||
}
|
||||
s.FilterProtocols = nonNil(r.FilterProtocols)
|
||||
s.FilterCountries = nonNil(r.FilterCountries)
|
||||
s.FilterSources = nonNil(r.FilterSources)
|
||||
s.MaxLatencyMs = r.MaxLatencyMs
|
||||
s.MinSpeedMbps = r.MinSpeedMbps
|
||||
s.LimitN = r.LimitN
|
||||
if r.UniqueIPs != nil {
|
||||
s.UniqueIPs = *r.UniqueIPs
|
||||
}
|
||||
s.UniqueIPsMetric = r.UniqueIPsMetric
|
||||
if s.UniqueIPsMetric != "latency" {
|
||||
s.UniqueIPsMetric = "speed"
|
||||
}
|
||||
s.SortBy = nonNil(r.SortBy)
|
||||
s.ExpiresAt = r.ExpiresAt
|
||||
}
|
||||
|
||||
func nonNil(v []string) []string {
|
||||
if v == nil {
|
||||
return []string{}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateSubscription(w http.ResponseWriter, r *http.Request) {
|
||||
var req subReq
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "bad request")
|
||||
return
|
||||
}
|
||||
sub := &db.Subscription{Token: newToken(), Enabled: true, Format: "plain", UniqueIPsMetric: "speed"}
|
||||
req.apply(sub)
|
||||
created, err := s.db.CreateSubscription(r.Context(), sub)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, created)
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateSubscription(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := pathID(r)
|
||||
if !ok {
|
||||
writeErr(w, http.StatusBadRequest, "bad id")
|
||||
return
|
||||
}
|
||||
var req subReq
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "bad request")
|
||||
return
|
||||
}
|
||||
sub := &db.Subscription{ID: id, Enabled: true, UniqueIPsMetric: "speed"}
|
||||
req.apply(sub)
|
||||
updated, err := s.db.UpdateSubscription(r.Context(), sub)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if updated == nil {
|
||||
writeErr(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, updated)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteSubscription(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := pathID(r)
|
||||
if !ok {
|
||||
writeErr(w, http.StatusBadRequest, "bad id")
|
||||
return
|
||||
}
|
||||
if err := s.db.DeleteSubscription(r.Context(), id); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func newToken() string {
|
||||
b := make([]byte, 16)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.qomar.pw/omar/zhguchiy_perchik/internal/db"
|
||||
)
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func writeErr(w http.ResponseWriter, status int, msg string) {
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
func decodeJSON(r *http.Request, dst any) error {
|
||||
dec := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 1<<20))
|
||||
dec.DisallowUnknownFields()
|
||||
return dec.Decode(dst)
|
||||
}
|
||||
|
||||
func pathID(r *http.Request) (int64, bool) {
|
||||
return parseID(r.PathValue("id"))
|
||||
}
|
||||
|
||||
func parseID(s string) (int64, bool) {
|
||||
n, err := strconv.ParseInt(s, 10, 64)
|
||||
if err != nil || n <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
|
||||
// csvParam splits a comma-separated query value into a trimmed slice.
|
||||
func csvParam(v string) []string {
|
||||
if strings.TrimSpace(v) == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(v, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func intPtrParam(v string) *int {
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
return &n
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func floatPtrParam(v string) *float64 {
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
if f, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
return &f
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseProxyFilter builds a ProxyFilter from URL query params shared by the
|
||||
// proxies list and export endpoints.
|
||||
func parseProxyFilter(r *http.Request) db.ProxyFilter {
|
||||
q := r.URL.Query()
|
||||
f := db.ProxyFilter{
|
||||
Protocols: csvParam(q.Get("protocol")),
|
||||
Countries: csvParam(q.Get("country")),
|
||||
Sources: csvParam(q.Get("source")),
|
||||
MaxLatencyMs: intPtrParam(q.Get("max_latency_ms")),
|
||||
MinSpeedMbps: floatPtrParam(q.Get("min_speed_mbps")),
|
||||
Search: strings.TrimSpace(q.Get("search")),
|
||||
}
|
||||
if v := q.Get("limit"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
f.Limit = n
|
||||
}
|
||||
}
|
||||
if v := q.Get("offset"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
f.Offset = n
|
||||
}
|
||||
}
|
||||
return f
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
// Package api serves the admin REST panel and the public subscription
|
||||
// endpoints over net/http (stdlib ServeMux with method+path patterns).
|
||||
package api
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.qomar.pw/omar/zhguchiy_perchik/internal/db"
|
||||
)
|
||||
|
||||
// Config holds the API server dependencies and secrets.
|
||||
type Config struct {
|
||||
DB *db.DB
|
||||
Log *slog.Logger
|
||||
AdminUser string
|
||||
AdminPassword string
|
||||
SessionSecret string
|
||||
AllowOrigin string // CORS origin for dev (empty = same-origin only)
|
||||
}
|
||||
|
||||
// Server is the HTTP application.
|
||||
type Server struct {
|
||||
cfg Config
|
||||
db *db.DB
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// NewServer builds a Server.
|
||||
func NewServer(cfg Config) *Server {
|
||||
return &Server{cfg: cfg, db: cfg.DB, log: cfg.Log}
|
||||
}
|
||||
|
||||
// Handler returns the root http.Handler with all routes mounted.
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Public.
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
})
|
||||
mux.HandleFunc("POST /api/v1/auth/login", s.handleLogin)
|
||||
mux.HandleFunc("POST /api/v1/auth/logout", s.handleLogout)
|
||||
mux.HandleFunc("GET /sub/{token}", s.handleSub)
|
||||
|
||||
// Admin (auth required).
|
||||
mux.Handle("GET /api/v1/auth/me", s.auth(s.handleMe))
|
||||
mux.Handle("GET /api/v1/dashboard", s.auth(s.handleDashboard))
|
||||
|
||||
mux.Handle("GET /api/v1/proxies", s.auth(s.handleListProxies))
|
||||
mux.Handle("GET /api/v1/proxies/export.txt", s.auth(s.handleExportProxies))
|
||||
mux.Handle("POST /api/v1/proxies/recheck", s.auth(s.handleRecheckProxies))
|
||||
mux.Handle("POST /api/v1/proxies/delete", s.auth(s.handleDeleteProxies))
|
||||
mux.Handle("GET /api/v1/proxies/facets", s.auth(s.handleProxyFacets))
|
||||
|
||||
mux.Handle("GET /api/v1/subscriptions", s.auth(s.handleListSubscriptions))
|
||||
mux.Handle("POST /api/v1/subscriptions", s.auth(s.handleCreateSubscription))
|
||||
mux.Handle("PATCH /api/v1/subscriptions/{id}", s.auth(s.handleUpdateSubscription))
|
||||
mux.Handle("DELETE /api/v1/subscriptions/{id}", s.auth(s.handleDeleteSubscription))
|
||||
|
||||
mux.Handle("GET /api/v1/sources", s.auth(s.handleListSources))
|
||||
mux.Handle("POST /api/v1/sources", s.auth(s.handleCreateSource))
|
||||
mux.Handle("PATCH /api/v1/sources/{id}", s.auth(s.handleUpdateSource))
|
||||
mux.Handle("DELETE /api/v1/sources/{id}", s.auth(s.handleDeleteSource))
|
||||
|
||||
mux.Handle("GET /api/v1/settings", s.auth(s.handleGetSettings))
|
||||
mux.Handle("PATCH /api/v1/settings", s.auth(s.handleUpdateSettings))
|
||||
|
||||
mux.Handle("GET /api/v1/checker/status", s.auth(s.handleCheckerStatus))
|
||||
mux.Handle("POST /api/v1/checker/run", s.auth(s.handleCheckerRun))
|
||||
mux.Handle("POST /api/v1/checker/stop", s.auth(s.handleCheckerStop))
|
||||
|
||||
return s.cors(logRequests(s.log, mux))
|
||||
}
|
||||
|
||||
// cors adds permissive CORS for the configured dev origin (credentialed).
|
||||
func (s *Server) cors(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
origin := r.Header.Get("Origin")
|
||||
if s.cfg.AllowOrigin != "" && origin == s.cfg.AllowOrigin {
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
w.Header().Set("Vary", "Origin")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS")
|
||||
}
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func logRequests(log *slog.Logger, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}
|
||||
next.ServeHTTP(sw, r)
|
||||
if !strings.HasPrefix(r.URL.Path, "/healthz") {
|
||||
log.Debug("http", "method", r.Method, "path", r.URL.Path,
|
||||
"status", sw.status, "ms", time.Since(start).Milliseconds())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type statusWriter struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
}
|
||||
|
||||
func (w *statusWriter) WriteHeader(code int) {
|
||||
w.status = code
|
||||
w.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
// Package checker runs the per-proxy validity pipeline: protocol handshake +
|
||||
// exit-IP discovery, latency gate, and (optionally) a speed gate. All three are
|
||||
// kill switches — a proxy is valid only if it passes every enabled gate.
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.qomar.pw/omar/zhguchiy_perchik/internal/dialer"
|
||||
"git.qomar.pw/omar/zhguchiy_perchik/internal/geoip"
|
||||
"git.qomar.pw/omar/zhguchiy_perchik/internal/upstream"
|
||||
)
|
||||
|
||||
// Config holds the tunable validity thresholds for one check pass.
|
||||
type Config struct {
|
||||
MaxLatencyMs int // proxies slower to first byte than this fail (0 = no gate)
|
||||
MinSpeedMbps float64 // proxies slower than this fail (0 = no gate)
|
||||
SpeedTestEnabled bool // when false the speed gate is skipped entirely
|
||||
SpeedTestURL string // download URL used to measure throughput
|
||||
DialTimeout time.Duration // per dial + latency probe timeout
|
||||
SpeedTestTimeout time.Duration // hard cap for the download test
|
||||
ExitIPAPIs []string // ordered IP-echo endpoints (fallback chain)
|
||||
}
|
||||
|
||||
// DefaultExitIPAPIs is the hardcoded fallback chain for exit-IP discovery. Not
|
||||
// admin-configurable by design — a reliable echo is required for every check.
|
||||
var DefaultExitIPAPIs = []string{
|
||||
"http://ifconfig.me/ip",
|
||||
"http://api.ipify.org",
|
||||
"http://icanhazip.com",
|
||||
"http://ipinfo.io/ip",
|
||||
}
|
||||
|
||||
// Result is the per-proxy outcome reported to the worker.
|
||||
type Result struct {
|
||||
Passed bool
|
||||
LatencyMs int
|
||||
SpeedMbps float64
|
||||
Country string
|
||||
ExitIP string
|
||||
FailReason string // empty iff Passed
|
||||
}
|
||||
|
||||
// Checker owns the dialer dispatcher and geoip resolver shared across all
|
||||
// concurrent per-proxy checks.
|
||||
type Checker struct {
|
||||
dispatch *dialer.Dispatcher
|
||||
geo *geoip.Resolver
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// New builds a Checker. The dispatcher and resolver are safe for concurrent use.
|
||||
func New(geo *geoip.Resolver, log *slog.Logger) *Checker {
|
||||
return &Checker{
|
||||
dispatch: dialer.NewDispatcher(log),
|
||||
geo: geo,
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
// proxyHTTPClient builds an http.Client whose transport tunnels through the
|
||||
// proxy: DialContext opens the protocol tunnel, TLS (for https targets) rides on
|
||||
// top. Keep-alives are disabled so each probe is an independent connection.
|
||||
func (c *Checker) proxyHTTPClient(up *upstream.Upstream, dialTO, totalTO time.Duration) *http.Client {
|
||||
tr := &http.Transport{
|
||||
DialContext: func(ctx context.Context, _, address string) (net.Conn, error) {
|
||||
dctx, cancel := context.WithTimeout(ctx, dialTO)
|
||||
defer cancel()
|
||||
return c.dispatch.Dial(dctx, up, address)
|
||||
},
|
||||
TLSHandshakeTimeout: dialTO,
|
||||
ResponseHeaderTimeout: totalTO,
|
||||
ExpectContinueTimeout: time.Second,
|
||||
ForceAttemptHTTP2: false,
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: false}, //nolint:gosec // target certs, not proxy
|
||||
DisableKeepAlives: true,
|
||||
}
|
||||
return &http.Client{
|
||||
Transport: tr,
|
||||
Timeout: totalTO,
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Check runs the full validity pipeline for one canonical proxy URL.
|
||||
func (c *Checker) Check(ctx context.Context, canonicalURL string, cfg Config) Result {
|
||||
up, err := upstream.ParseURL(canonicalURL)
|
||||
if err != nil {
|
||||
return Result{FailReason: "parse: " + err.Error()}
|
||||
}
|
||||
|
||||
res := c.ping(ctx, up, cfg)
|
||||
if !res.Passed {
|
||||
return res
|
||||
}
|
||||
|
||||
if cfg.SpeedTestEnabled && cfg.SpeedTestURL != "" {
|
||||
mbps, serr := c.speedTest(ctx, up, cfg)
|
||||
res.SpeedMbps = mbps
|
||||
if serr != nil {
|
||||
res.Passed = false
|
||||
res.FailReason = "speed: " + truncErr(serr)
|
||||
return res
|
||||
}
|
||||
if cfg.MinSpeedMbps > 0 && mbps < cfg.MinSpeedMbps {
|
||||
res.Passed = false
|
||||
res.FailReason = fmt.Sprintf("speed %.2fMbps < %.2fMbps", mbps, cfg.MinSpeedMbps)
|
||||
return res
|
||||
}
|
||||
}
|
||||
|
||||
res.Passed = true
|
||||
return res
|
||||
}
|
||||
|
||||
// ping dials the proxy, fetches its exit IP from the fallback chain and applies
|
||||
// the latency gate. Sets Country from the geoip resolver.
|
||||
func (c *Checker) ping(ctx context.Context, up *upstream.Upstream, cfg Config) Result {
|
||||
var res Result
|
||||
client := c.proxyHTTPClient(up, cfg.DialTimeout, cfg.DialTimeout)
|
||||
|
||||
apis := cfg.ExitIPAPIs
|
||||
if len(apis) == 0 {
|
||||
apis = DefaultExitIPAPIs
|
||||
}
|
||||
|
||||
var lastErr string
|
||||
for _, api := range apis {
|
||||
start := time.Now()
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, api, nil)
|
||||
req.Header.Set("User-Agent", "curl/8.0")
|
||||
resp, err := client.Do(req)
|
||||
latency := int(time.Since(start).Milliseconds())
|
||||
if err != nil {
|
||||
lastErr = truncErr(err)
|
||||
if ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4*1024))
|
||||
resp.Body.Close()
|
||||
|
||||
ip := strings.TrimSpace(string(body))
|
||||
if net.ParseIP(ip) == nil {
|
||||
ip = extractIP(string(body))
|
||||
}
|
||||
if ip == "" {
|
||||
lastErr = "invalid exit-ip response"
|
||||
continue
|
||||
}
|
||||
res.LatencyMs = latency
|
||||
res.ExitIP = ip
|
||||
if country, cerr := c.geo.LookupString(ip); cerr == nil {
|
||||
res.Country = country
|
||||
}
|
||||
if cfg.MaxLatencyMs > 0 && latency > cfg.MaxLatencyMs {
|
||||
res.FailReason = fmt.Sprintf("latency %dms > %dms", latency, cfg.MaxLatencyMs)
|
||||
return res
|
||||
}
|
||||
res.Passed = true
|
||||
return res
|
||||
}
|
||||
|
||||
if lastErr == "" {
|
||||
lastErr = "no exit-ip endpoint reachable"
|
||||
}
|
||||
res.FailReason = "dial: " + lastErr
|
||||
return res
|
||||
}
|
||||
|
||||
// speedTest downloads the target through the proxy and returns Mbps.
|
||||
func (c *Checker) speedTest(ctx context.Context, up *upstream.Upstream, cfg Config) (float64, error) {
|
||||
client := c.proxyHTTPClient(up, cfg.DialTimeout, cfg.SpeedTestTimeout)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, cfg.SpeedTestURL, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "curl/8.0")
|
||||
start := time.Now()
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
n, _ := io.Copy(io.Discard, resp.Body)
|
||||
elapsed := time.Since(start).Seconds()
|
||||
if elapsed <= 0 || n <= 0 {
|
||||
return 0, fmt.Errorf("empty body or zero elapsed")
|
||||
}
|
||||
return float64(n) * 8 / 1024 / 1024 / elapsed, nil
|
||||
}
|
||||
|
||||
func extractIP(s string) string {
|
||||
for _, tok := range strings.FieldsFunc(s, func(r rune) bool {
|
||||
return r == ',' || r == ' ' || r == '"' || r == ':' ||
|
||||
r == '\n' || r == '\r' || r == '{' || r == '}'
|
||||
}) {
|
||||
if net.ParseIP(tok) != nil {
|
||||
return tok
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func truncErr(err error) string {
|
||||
s := err.Error()
|
||||
if len(s) <= 120 {
|
||||
return s
|
||||
}
|
||||
return s[:120] + "…"
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// Package config maps the settings key/value table onto a typed struct used by
|
||||
// the checker and API. Unknown keys are ignored; missing keys fall back to the
|
||||
// documented defaults.
|
||||
package config
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Setting keys stored in the settings table.
|
||||
const (
|
||||
KeyCheckIntervalHours = "check_interval_hours"
|
||||
KeyWorkers = "workers"
|
||||
KeyTimeoutSec = "timeout_sec"
|
||||
KeyMaxLatencyMs = "max_latency_ms"
|
||||
KeyMinSpeedMbps = "min_speed_mbps"
|
||||
KeySpeedTestEnabled = "speedtest_enabled"
|
||||
KeySpeedTestURL = "speedtest_url"
|
||||
KeyGeoIPDBURL = "geoip_db_url"
|
||||
KeyAutoEnabled = "auto_enabled"
|
||||
KeyTelegramBotToken = "telegram_bot_token"
|
||||
KeyTelegramChatID = "telegram_chat_id"
|
||||
KeyTelegramNotifyStart = "telegram_notify_start"
|
||||
KeyTelegramNotifyFinish = "telegram_notify_finish"
|
||||
)
|
||||
|
||||
// Settings is the parsed, typed view of the settings table.
|
||||
type Settings struct {
|
||||
CheckIntervalHours float64
|
||||
Workers int
|
||||
TimeoutSec int
|
||||
MaxLatencyMs int
|
||||
MinSpeedMbps float64
|
||||
SpeedTestEnabled bool
|
||||
SpeedTestURL string
|
||||
GeoIPDBURL string
|
||||
AutoEnabled bool
|
||||
TelegramBotToken string
|
||||
TelegramChatID string
|
||||
TelegramNotifyStart bool
|
||||
TelegramNotifyFinish bool
|
||||
}
|
||||
|
||||
// Default returns settings matching the migration defaults.
|
||||
func Default() Settings {
|
||||
return Settings{
|
||||
CheckIntervalHours: 12,
|
||||
Workers: 10,
|
||||
TimeoutSec: 5,
|
||||
MaxLatencyMs: 1000,
|
||||
MinSpeedMbps: 3,
|
||||
SpeedTestEnabled: true,
|
||||
SpeedTestURL: "https://speed.cloudflare.com/__down?bytes=10000000",
|
||||
GeoIPDBURL: "https://cdn.jsdelivr.net/npm/@ip-location-db/geolite2-geo-whois-asn-country-mmdb/geolite2-geo-whois-asn-country.mmdb",
|
||||
AutoEnabled: true,
|
||||
TelegramNotifyFinish: true,
|
||||
}
|
||||
}
|
||||
|
||||
// FromMap overlays raw key/value strings onto the defaults.
|
||||
func FromMap(m map[string]string) Settings {
|
||||
s := Default()
|
||||
for k, v := range m {
|
||||
switch k {
|
||||
case KeyCheckIntervalHours:
|
||||
s.CheckIntervalHours = parseFloat(v, s.CheckIntervalHours)
|
||||
case KeyWorkers:
|
||||
s.Workers = parseInt(v, s.Workers)
|
||||
case KeyTimeoutSec:
|
||||
s.TimeoutSec = parseInt(v, s.TimeoutSec)
|
||||
case KeyMaxLatencyMs:
|
||||
s.MaxLatencyMs = parseInt(v, s.MaxLatencyMs)
|
||||
case KeyMinSpeedMbps:
|
||||
s.MinSpeedMbps = parseFloat(v, s.MinSpeedMbps)
|
||||
case KeySpeedTestEnabled:
|
||||
s.SpeedTestEnabled = parseBool(v, s.SpeedTestEnabled)
|
||||
case KeySpeedTestURL:
|
||||
s.SpeedTestURL = v
|
||||
case KeyGeoIPDBURL:
|
||||
if v != "" {
|
||||
s.GeoIPDBURL = v
|
||||
}
|
||||
case KeyAutoEnabled:
|
||||
s.AutoEnabled = parseBool(v, s.AutoEnabled)
|
||||
case KeyTelegramBotToken:
|
||||
s.TelegramBotToken = v
|
||||
case KeyTelegramChatID:
|
||||
s.TelegramChatID = v
|
||||
case KeyTelegramNotifyStart:
|
||||
s.TelegramNotifyStart = parseBool(v, s.TelegramNotifyStart)
|
||||
case KeyTelegramNotifyFinish:
|
||||
s.TelegramNotifyFinish = parseBool(v, s.TelegramNotifyFinish)
|
||||
}
|
||||
}
|
||||
if s.Workers < 1 {
|
||||
s.Workers = 1
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Timeout returns the dial timeout as a duration.
|
||||
func (s Settings) Timeout() time.Duration {
|
||||
if s.TimeoutSec <= 0 {
|
||||
return 5 * time.Second
|
||||
}
|
||||
return time.Duration(s.TimeoutSec) * time.Second
|
||||
}
|
||||
|
||||
func parseInt(v string, def int) int {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
return n
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func parseFloat(v string, def float64) float64 {
|
||||
if f, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
return f
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func parseBool(v string, def bool) bool {
|
||||
if b, err := strconv.ParseBool(v); err == nil {
|
||||
return b
|
||||
}
|
||||
return def
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// Package db is the PostgreSQL access layer. It owns a pgx pool and exposes
|
||||
// query methods for the checker worker and the API. The schema is embedded and
|
||||
// applied idempotently on boot.
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
//go:embed schema.sql
|
||||
var schemaSQL string
|
||||
|
||||
// DB wraps a pgx connection pool.
|
||||
type DB struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// New opens a pool against dsn and verifies connectivity.
|
||||
func New(ctx context.Context, dsn string) (*DB, error) {
|
||||
cfg, err := pgxpool.ParseConfig(dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse dsn: %w", err)
|
||||
}
|
||||
cfg.MaxConns = 20
|
||||
cfg.MaxConnIdleTime = 5 * time.Minute
|
||||
|
||||
pool, err := pgxpool.NewWithConfig(ctx, cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new pool: %w", err)
|
||||
}
|
||||
pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
if err := pool.Ping(pingCtx); err != nil {
|
||||
pool.Close()
|
||||
return nil, fmt.Errorf("ping: %w", err)
|
||||
}
|
||||
return &DB{pool: pool}, nil
|
||||
}
|
||||
|
||||
// Close releases the pool.
|
||||
func (d *DB) Close() { d.pool.Close() }
|
||||
|
||||
// Pool exposes the underlying pool for callers needing transactions.
|
||||
func (d *DB) Pool() *pgxpool.Pool { return d.pool }
|
||||
|
||||
// ApplySchema runs the embedded schema (idempotent — all statements use IF NOT
|
||||
// EXISTS / ON CONFLICT DO NOTHING).
|
||||
func (d *DB) ApplySchema(ctx context.Context) error {
|
||||
if _, err := d.pool.Exec(ctx, schemaSQL); err != nil {
|
||||
return fmt.Errorf("apply schema: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- settings -------------------------------------------------------------
|
||||
|
||||
// GetSettings returns every settings row as a map.
|
||||
func (d *DB) GetSettings(ctx context.Context) (map[string]string, error) {
|
||||
rows, err := d.pool.Query(ctx, `SELECT key, value FROM settings`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make(map[string]string)
|
||||
for rows.Next() {
|
||||
var k, v string
|
||||
if err := rows.Scan(&k, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetSetting returns a single setting value and whether it exists.
|
||||
func (d *DB) GetSetting(ctx context.Context, key string) (string, bool, error) {
|
||||
var v string
|
||||
err := d.pool.QueryRow(ctx, `SELECT value FROM settings WHERE key = $1`, key).Scan(&v)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
return v, true, nil
|
||||
}
|
||||
|
||||
// SetSetting upserts a single key.
|
||||
func (d *DB) SetSetting(ctx context.Context, key, value string) error {
|
||||
_, err := d.pool.Exec(ctx,
|
||||
`INSERT INTO settings (key, value, updated_at) VALUES ($1, $2, now())
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`,
|
||||
key, value)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetSettings upserts many keys in one transaction.
|
||||
func (d *DB) SetSettings(ctx context.Context, kv map[string]string) error {
|
||||
tx, err := d.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx) //nolint:errcheck // no-op after successful commit
|
||||
for k, v := range kv {
|
||||
if _, err := tx.Exec(ctx,
|
||||
`INSERT INTO settings (key, value, updated_at) VALUES ($1, $2, now())
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`,
|
||||
k, v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package db
|
||||
|
||||
import "time"
|
||||
|
||||
// Source is a proxy source list.
|
||||
type Source struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Enabled bool `json:"enabled"`
|
||||
LastFetchedAt *time.Time `json:"last_fetched_at"`
|
||||
LastLineCount int `json:"last_line_count"`
|
||||
LastError string `json:"last_error"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Session is one check cycle.
|
||||
type Session struct {
|
||||
ID int64 `json:"id"`
|
||||
Status string `json:"status"`
|
||||
TriggerKind string `json:"trigger_kind"`
|
||||
IsCurrent bool `json:"is_current"`
|
||||
CancelRequested bool `json:"cancel_requested"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
FinishedAt *time.Time `json:"finished_at"`
|
||||
DurationMs int64 `json:"duration_ms"`
|
||||
TotalRawLines int `json:"total_raw_lines"`
|
||||
UniqueCandidates int `json:"unique_candidates"`
|
||||
Collapsed int `json:"collapsed"`
|
||||
ProgressTotal int `json:"progress_total"`
|
||||
ProgressDone int `json:"progress_done"`
|
||||
Valid int `json:"valid"`
|
||||
Invalid int `json:"invalid"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// SessionProxy is a working proxy of a session with denormalized metrics.
|
||||
type SessionProxy struct {
|
||||
ProxyID int64 `json:"proxy_id"`
|
||||
CanonicalURL string `json:"url"`
|
||||
Protocol string `json:"protocol"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
SourceName string `json:"source"`
|
||||
LatencyMs int `json:"latency_ms"`
|
||||
SpeedMbps float64 `json:"speed_mbps"`
|
||||
Country string `json:"country"`
|
||||
ExitIP string `json:"exit_ip"`
|
||||
IsNew bool `json:"is_new"`
|
||||
// Uptime metadata joined from proxies for the Proxies tab.
|
||||
FirstSeen *time.Time `json:"first_seen,omitempty"`
|
||||
LastAlive *time.Time `json:"last_alive,omitempty"`
|
||||
ConsecutiveFailures int `json:"consecutive_failures,omitempty"`
|
||||
}
|
||||
|
||||
// Subscription is a client sub-link definition.
|
||||
type Subscription struct {
|
||||
ID int64 `json:"id"`
|
||||
Token string `json:"token"`
|
||||
Name string `json:"name"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Format string `json:"format"`
|
||||
FilterProtocols []string `json:"filter_protocols"`
|
||||
FilterCountries []string `json:"filter_countries"`
|
||||
FilterSources []string `json:"filter_sources"`
|
||||
MaxLatencyMs *int `json:"max_latency_ms"`
|
||||
MinSpeedMbps *float64 `json:"min_speed_mbps"`
|
||||
LimitN *int `json:"limit_n"`
|
||||
UniqueIPs bool `json:"unique_ips"`
|
||||
UniqueIPsMetric string `json:"unique_ips_metric"`
|
||||
SortBy []string `json:"sort_by"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
RequestCount int64 `json:"request_count"`
|
||||
LastRequestedAt *time.Time `json:"last_requested_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// ProxyFilter is the set of filters shared by the proxies list and subscription
|
||||
// serving. Zero-value fields mean "no constraint".
|
||||
type ProxyFilter struct {
|
||||
Protocols []string
|
||||
Countries []string
|
||||
Sources []string
|
||||
MaxLatencyMs *int
|
||||
MinSpeedMbps *float64
|
||||
Search string
|
||||
// UniqueIPs and Metric are applied in-memory after the DB scan.
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package db
|
||||
|
||||
import "context"
|
||||
|
||||
// EnsureProxy inserts a proxy history row for a canonical URL if absent (pinning
|
||||
// the first-seen source), and returns its id plus whether it was newly inserted.
|
||||
// On conflict the source is left untouched (first-seen attribution).
|
||||
func (d *DB) EnsureProxy(ctx context.Context, canonicalURL, protocol, host string, port int, sourceID *int64, sourceName string) (id int64, inserted bool, err error) {
|
||||
err = d.pool.QueryRow(ctx,
|
||||
`INSERT INTO proxies (canonical_url, protocol, host, port, source_id, source_name)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (canonical_url) DO UPDATE SET updated_at = now()
|
||||
RETURNING id, (xmax = 0) AS inserted`,
|
||||
canonicalURL, protocol, host, port, sourceID, sourceName).Scan(&id, &inserted)
|
||||
return id, inserted, err
|
||||
}
|
||||
|
||||
// MarkProxyPass records a successful check on the history row.
|
||||
func (d *DB) MarkProxyPass(ctx context.Context, id int64, latencyMs int, speedMbps float64, country, exitIP string) error {
|
||||
_, err := d.pool.Exec(ctx,
|
||||
`UPDATE proxies SET
|
||||
last_alive = now(),
|
||||
consecutive_failures = 0,
|
||||
total_checks = total_checks + 1,
|
||||
total_passes = total_passes + 1,
|
||||
last_latency_ms = $2,
|
||||
last_speed_mbps = $3,
|
||||
last_country = $4,
|
||||
last_exit_ip = $5,
|
||||
updated_at = now()
|
||||
WHERE id = $1`,
|
||||
id, latencyMs, speedMbps, country, exitIP)
|
||||
return err
|
||||
}
|
||||
|
||||
// MarkProxyFail records a failed check on the history row.
|
||||
func (d *DB) MarkProxyFail(ctx context.Context, id int64) error {
|
||||
_, err := d.pool.Exec(ctx,
|
||||
`UPDATE proxies SET
|
||||
consecutive_failures = consecutive_failures + 1,
|
||||
total_checks = total_checks + 1,
|
||||
updated_at = now()
|
||||
WHERE id = $1`,
|
||||
id)
|
||||
return err
|
||||
}
|
||||
|
||||
// ProxyRef is a minimal reference used when rechecking a selection.
|
||||
type ProxyRef struct {
|
||||
ID int64
|
||||
CanonicalURL string
|
||||
Protocol string
|
||||
Host string
|
||||
Port int
|
||||
SourceName string
|
||||
}
|
||||
|
||||
// GetProxyRefs returns canonical URLs for the given proxy ids.
|
||||
func (d *DB) GetProxyRefs(ctx context.Context, ids []int64) ([]ProxyRef, error) {
|
||||
rows, err := d.pool.Query(ctx,
|
||||
`SELECT id, canonical_url, protocol, host, port, source_name
|
||||
FROM proxies WHERE id = ANY($1)`, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []ProxyRef
|
||||
for rows.Next() {
|
||||
var r ProxyRef
|
||||
if err := rows.Scan(&r.ID, &r.CanonicalURL, &r.Protocol, &r.Host, &r.Port, &r.SourceName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
-- zhguchiy_perchik schema. Applied idempotently by the checker on boot.
|
||||
-- Model: "only working / last completed session". The panel and subscriptions
|
||||
-- always read the single check_sessions row with is_current = true.
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- settings: typed key/value config editable from the panel.
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key text PRIMARY KEY,
|
||||
value text NOT NULL,
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- sources: proxy source lists (txt / subscription URLs).
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS sources (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
name text NOT NULL,
|
||||
url text NOT NULL UNIQUE,
|
||||
enabled boolean NOT NULL DEFAULT true,
|
||||
last_fetched_at timestamptz,
|
||||
last_line_count integer NOT NULL DEFAULT 0,
|
||||
last_error text NOT NULL DEFAULT '',
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- proxies: persistent per-canonical-URL history. Kept even while a proxy is
|
||||
-- currently invalid. Never served directly — only working ones from the
|
||||
-- current session are. Source is pinned first-seen.
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS proxies (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
canonical_url text NOT NULL UNIQUE,
|
||||
protocol text NOT NULL,
|
||||
host text NOT NULL,
|
||||
port integer NOT NULL,
|
||||
source_id bigint REFERENCES sources(id) ON DELETE SET NULL,
|
||||
source_name text NOT NULL DEFAULT '',
|
||||
first_seen timestamptz NOT NULL DEFAULT now(),
|
||||
last_alive timestamptz,
|
||||
consecutive_failures integer NOT NULL DEFAULT 0,
|
||||
total_checks bigint NOT NULL DEFAULT 0,
|
||||
total_passes bigint NOT NULL DEFAULT 0,
|
||||
last_latency_ms integer NOT NULL DEFAULT 0,
|
||||
last_speed_mbps double precision NOT NULL DEFAULT 0,
|
||||
last_country text NOT NULL DEFAULT '',
|
||||
last_exit_ip text NOT NULL DEFAULT '',
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_proxies_protocol ON proxies (protocol);
|
||||
CREATE INDEX IF NOT EXISTS idx_proxies_last_country ON proxies (last_country);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- check_sessions: one per check cycle. Kept forever (trends). Exactly one row
|
||||
-- has is_current = true (enforced by the partial unique index below).
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS check_sessions (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
status text NOT NULL DEFAULT 'running', -- running|completed|cancelled|failed
|
||||
trigger_kind text NOT NULL DEFAULT 'scheduled', -- scheduled|manual
|
||||
is_current boolean NOT NULL DEFAULT false,
|
||||
cancel_requested boolean NOT NULL DEFAULT false,
|
||||
started_at timestamptz NOT NULL DEFAULT now(),
|
||||
finished_at timestamptz,
|
||||
duration_ms bigint NOT NULL DEFAULT 0,
|
||||
total_raw_lines integer NOT NULL DEFAULT 0,
|
||||
unique_candidates integer NOT NULL DEFAULT 0,
|
||||
collapsed integer NOT NULL DEFAULT 0, -- raw - unique (dedup/junk count)
|
||||
progress_total integer NOT NULL DEFAULT 0,
|
||||
progress_done integer NOT NULL DEFAULT 0,
|
||||
valid integer NOT NULL DEFAULT 0,
|
||||
invalid integer NOT NULL DEFAULT 0,
|
||||
error text NOT NULL DEFAULT ''
|
||||
);
|
||||
-- exactly one current session
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uniq_current_session
|
||||
ON check_sessions (is_current) WHERE is_current;
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_status_finished
|
||||
ON check_sessions (status, finished_at DESC);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- session_proxies: working proxies of a session with metrics at that session.
|
||||
-- Denormalized so subscription/proxy-list serving is a single filtered scan.
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS session_proxies (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
session_id bigint NOT NULL REFERENCES check_sessions(id) ON DELETE CASCADE,
|
||||
proxy_id bigint NOT NULL REFERENCES proxies(id) ON DELETE CASCADE,
|
||||
canonical_url text NOT NULL,
|
||||
protocol text NOT NULL,
|
||||
host text NOT NULL,
|
||||
port integer NOT NULL,
|
||||
source_name text NOT NULL DEFAULT '',
|
||||
latency_ms integer NOT NULL DEFAULT 0,
|
||||
speed_mbps double precision NOT NULL DEFAULT 0,
|
||||
country text NOT NULL DEFAULT '',
|
||||
exit_ip text NOT NULL DEFAULT '',
|
||||
is_new boolean NOT NULL DEFAULT false,
|
||||
UNIQUE (session_id, proxy_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sp_session ON session_proxies (session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sp_session_protocol ON session_proxies (session_id, protocol);
|
||||
CREATE INDEX IF NOT EXISTS idx_sp_session_country ON session_proxies (session_id, country);
|
||||
CREATE INDEX IF NOT EXISTS idx_sp_session_exitip ON session_proxies (session_id, exit_ip);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Compact per-session aggregate stats (protocol / source breakdown incl. valid%).
|
||||
-- We do NOT store every failed candidate — only these counters.
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS session_protocol_stats (
|
||||
session_id bigint NOT NULL REFERENCES check_sessions(id) ON DELETE CASCADE,
|
||||
protocol text NOT NULL,
|
||||
checked integer NOT NULL DEFAULT 0,
|
||||
passed integer NOT NULL DEFAULT 0,
|
||||
failed integer NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (session_id, protocol)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS session_source_stats (
|
||||
session_id bigint NOT NULL REFERENCES check_sessions(id) ON DELETE CASCADE,
|
||||
source_name text NOT NULL,
|
||||
raw_lines integer NOT NULL DEFAULT 0,
|
||||
unique_candidates integer NOT NULL DEFAULT 0,
|
||||
passed integer NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (session_id, source_name)
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- subscriptions: client sub-links with filters, format, unique_ips, sort, ttl.
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS subscriptions (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
token text NOT NULL UNIQUE,
|
||||
name text NOT NULL DEFAULT '',
|
||||
enabled boolean NOT NULL DEFAULT true,
|
||||
format text NOT NULL DEFAULT 'plain', -- plain|base64|clash|singbox
|
||||
filter_protocols text[] NOT NULL DEFAULT '{}',
|
||||
filter_countries text[] NOT NULL DEFAULT '{}',
|
||||
filter_sources text[] NOT NULL DEFAULT '{}',
|
||||
max_latency_ms integer,
|
||||
min_speed_mbps double precision,
|
||||
limit_n integer,
|
||||
unique_ips boolean NOT NULL DEFAULT false,
|
||||
unique_ips_metric text NOT NULL DEFAULT 'speed', -- speed|latency
|
||||
sort_by text[] NOT NULL DEFAULT '{}', -- e.g. {speed:desc,latency:asc}
|
||||
expires_at timestamptz,
|
||||
request_count bigint NOT NULL DEFAULT 0,
|
||||
last_requested_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Default settings (only inserted when missing).
|
||||
-- ---------------------------------------------------------------------------
|
||||
INSERT INTO settings (key, value) VALUES
|
||||
('check_interval_hours', '12'),
|
||||
('workers', '10'),
|
||||
('timeout_sec', '5'),
|
||||
('max_latency_ms', '1000'),
|
||||
('min_speed_mbps', '3'),
|
||||
('speedtest_enabled', 'true'),
|
||||
('speedtest_url', 'https://speed.cloudflare.com/__down?bytes=10000000'),
|
||||
('geoip_db_url', 'https://cdn.jsdelivr.net/npm/@ip-location-db/geolite2-geo-whois-asn-country-mmdb/geolite2-geo-whois-asn-country.mmdb'),
|
||||
('auto_enabled', 'true'),
|
||||
('telegram_bot_token', ''),
|
||||
('telegram_chat_id', ''),
|
||||
('telegram_notify_start', 'false'),
|
||||
('telegram_notify_finish', 'true')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
@@ -0,0 +1,345 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// argf formats a single positional-parameter fragment, e.g. argf(" AND x=$%d", 3).
|
||||
func argf(format string, n int) string { return fmt.Sprintf(format, n) }
|
||||
|
||||
// searchClause builds the ILIKE search fragment reusing the same parameter index.
|
||||
func searchClause(idx int) string {
|
||||
return fmt.Sprintf(` AND (sp.canonical_url ILIKE $%d OR sp.host ILIKE $%d OR sp.exit_ip ILIKE $%d)`, idx, idx, idx)
|
||||
}
|
||||
|
||||
// CreateSession opens a new running session and returns its id.
|
||||
func (d *DB) CreateSession(ctx context.Context, triggerKind string) (int64, error) {
|
||||
var id int64
|
||||
err := d.pool.QueryRow(ctx,
|
||||
`INSERT INTO check_sessions (status, trigger_kind) VALUES ('running', $1) RETURNING id`,
|
||||
triggerKind).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
// SetSessionTotals records the import accounting for a session.
|
||||
func (d *DB) SetSessionTotals(ctx context.Context, id int64, totalRaw, uniqueCandidates, collapsed, progressTotal int) error {
|
||||
_, err := d.pool.Exec(ctx,
|
||||
`UPDATE check_sessions
|
||||
SET total_raw_lines = $2, unique_candidates = $3, collapsed = $4, progress_total = $5
|
||||
WHERE id = $1`,
|
||||
id, totalRaw, uniqueCandidates, collapsed, progressTotal)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateSessionProgress updates the live progress + running valid/invalid tally.
|
||||
func (d *DB) UpdateSessionProgress(ctx context.Context, id int64, done, valid, invalid int) error {
|
||||
_, err := d.pool.Exec(ctx,
|
||||
`UPDATE check_sessions SET progress_done = $2, valid = $3, invalid = $4 WHERE id = $1`,
|
||||
id, done, valid, invalid)
|
||||
return err
|
||||
}
|
||||
|
||||
// AddSessionProxy records a working proxy for the session (denormalized
|
||||
// metrics). On conflict it refreshes the metrics but keeps the original is_new
|
||||
// flag — this lets "recheck selected" update a proxy in the current session.
|
||||
func (d *DB) AddSessionProxy(ctx context.Context, sp *SessionProxy, sessionID int64) error {
|
||||
_, err := d.pool.Exec(ctx,
|
||||
`INSERT INTO session_proxies
|
||||
(session_id, proxy_id, canonical_url, protocol, host, port, source_name,
|
||||
latency_ms, speed_mbps, country, exit_ip, is_new)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
|
||||
ON CONFLICT (session_id, proxy_id) DO UPDATE SET
|
||||
latency_ms = EXCLUDED.latency_ms,
|
||||
speed_mbps = EXCLUDED.speed_mbps,
|
||||
country = EXCLUDED.country,
|
||||
exit_ip = EXCLUDED.exit_ip`,
|
||||
sessionID, sp.ProxyID, sp.CanonicalURL, sp.Protocol, sp.Host, sp.Port, sp.SourceName,
|
||||
sp.LatencyMs, sp.SpeedMbps, sp.Country, sp.ExitIP, sp.IsNew)
|
||||
return err
|
||||
}
|
||||
|
||||
// CurrentSessionProxyIDs returns the proxy ids present in the current session
|
||||
// (used to compute the is_new flag for the next session).
|
||||
func (d *DB) CurrentSessionProxyIDs(ctx context.Context) (map[int64]struct{}, error) {
|
||||
rows, err := d.pool.Query(ctx,
|
||||
`SELECT sp.proxy_id FROM session_proxies sp
|
||||
JOIN check_sessions cs ON cs.id = sp.session_id
|
||||
WHERE cs.is_current`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make(map[int64]struct{})
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[id] = struct{}{}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// BumpProtocolStat increments the per-protocol counters for a session.
|
||||
func (d *DB) BumpProtocolStat(ctx context.Context, sessionID int64, protocol string, passed bool) error {
|
||||
pass, fail := 0, 1
|
||||
if passed {
|
||||
pass, fail = 1, 0
|
||||
}
|
||||
_, err := d.pool.Exec(ctx,
|
||||
`INSERT INTO session_protocol_stats (session_id, protocol, checked, passed, failed)
|
||||
VALUES ($1, $2, 1, $3, $4)
|
||||
ON CONFLICT (session_id, protocol) DO UPDATE SET
|
||||
checked = session_protocol_stats.checked + 1,
|
||||
passed = session_protocol_stats.passed + $3,
|
||||
failed = session_protocol_stats.failed + $4`,
|
||||
sessionID, protocol, pass, fail)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpsertSourceStat records/updates a source's contribution to a session.
|
||||
func (d *DB) UpsertSourceStat(ctx context.Context, sessionID int64, sourceName string, rawLines, uniqueCandidates int) error {
|
||||
_, err := d.pool.Exec(ctx,
|
||||
`INSERT INTO session_source_stats (session_id, source_name, raw_lines, unique_candidates, passed)
|
||||
VALUES ($1, $2, $3, $4, 0)
|
||||
ON CONFLICT (session_id, source_name) DO UPDATE SET
|
||||
raw_lines = session_source_stats.raw_lines + $3,
|
||||
unique_candidates = session_source_stats.unique_candidates + $4`,
|
||||
sessionID, sourceName, rawLines, uniqueCandidates)
|
||||
return err
|
||||
}
|
||||
|
||||
// BumpSourcePassed increments a source's passed count for a session.
|
||||
func (d *DB) BumpSourcePassed(ctx context.Context, sessionID int64, sourceName string) error {
|
||||
_, err := d.pool.Exec(ctx,
|
||||
`INSERT INTO session_source_stats (session_id, source_name, raw_lines, unique_candidates, passed)
|
||||
VALUES ($1, $2, 0, 0, 1)
|
||||
ON CONFLICT (session_id, source_name) DO UPDATE SET
|
||||
passed = session_source_stats.passed + 1`,
|
||||
sessionID, sourceName)
|
||||
return err
|
||||
}
|
||||
|
||||
// CompleteSession finalizes a session and atomically makes it the current one:
|
||||
// the previous current session is unset and this one set, in a single tx so the
|
||||
// panel/subscriptions switch over only on successful completion.
|
||||
func (d *DB) CompleteSession(ctx context.Context, id int64, durationMs int64) error {
|
||||
tx, err := d.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx) //nolint:errcheck // no-op after commit
|
||||
|
||||
if _, err := tx.Exec(ctx,
|
||||
`UPDATE check_sessions SET is_current = false WHERE is_current AND id <> $1`, id); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx,
|
||||
`UPDATE check_sessions
|
||||
SET status = 'completed', is_current = true, finished_at = now(), duration_ms = $2
|
||||
WHERE id = $1`,
|
||||
id, durationMs); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// FailSession marks a session cancelled/failed without touching the current pointer.
|
||||
func (d *DB) FailSession(ctx context.Context, id int64, status, errMsg string, durationMs int64) error {
|
||||
_, err := d.pool.Exec(ctx,
|
||||
`UPDATE check_sessions
|
||||
SET status = $2, finished_at = now(), duration_ms = $3, error = $4
|
||||
WHERE id = $1`,
|
||||
id, status, durationMs, errMsg)
|
||||
return err
|
||||
}
|
||||
|
||||
// RequestCancel sets the cancel flag on a running session.
|
||||
func (d *DB) RequestCancel(ctx context.Context, id int64) error {
|
||||
_, err := d.pool.Exec(ctx,
|
||||
`UPDATE check_sessions SET cancel_requested = true WHERE id = $1 AND status = 'running'`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// IsCancelRequested reports whether cancel was requested for a session.
|
||||
func (d *DB) IsCancelRequested(ctx context.Context, id int64) (bool, error) {
|
||||
var v bool
|
||||
err := d.pool.QueryRow(ctx,
|
||||
`SELECT cancel_requested FROM check_sessions WHERE id = $1`, id).Scan(&v)
|
||||
return v, err
|
||||
}
|
||||
|
||||
// GetRunningSession returns the currently running session, if any.
|
||||
func (d *DB) GetRunningSession(ctx context.Context) (*Session, error) {
|
||||
return d.scanSession(ctx,
|
||||
`SELECT `+sessionCols+` FROM check_sessions WHERE status = 'running' ORDER BY started_at DESC LIMIT 1`)
|
||||
}
|
||||
|
||||
// GetCurrentSession returns the last completed (current) session, if any.
|
||||
func (d *DB) GetCurrentSession(ctx context.Context) (*Session, error) {
|
||||
return d.scanSession(ctx,
|
||||
`SELECT `+sessionCols+` FROM check_sessions WHERE is_current LIMIT 1`)
|
||||
}
|
||||
|
||||
const sessionCols = `id, status, trigger_kind, is_current, cancel_requested, started_at,
|
||||
finished_at, duration_ms, total_raw_lines, unique_candidates, collapsed,
|
||||
progress_total, progress_done, valid, invalid, error`
|
||||
|
||||
func (d *DB) scanSession(ctx context.Context, query string, args ...any) (*Session, error) {
|
||||
var s Session
|
||||
err := d.pool.QueryRow(ctx, query, args...).Scan(
|
||||
&s.ID, &s.Status, &s.TriggerKind, &s.IsCurrent, &s.CancelRequested, &s.StartedAt,
|
||||
&s.FinishedAt, &s.DurationMs, &s.TotalRawLines, &s.UniqueCandidates, &s.Collapsed,
|
||||
&s.ProgressTotal, &s.ProgressDone, &s.Valid, &s.Invalid, &s.Error)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
// RecentSessions returns the most recent sessions (for the history/trends view).
|
||||
func (d *DB) RecentSessions(ctx context.Context, limit int) ([]Session, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
rows, err := d.pool.Query(ctx,
|
||||
`SELECT `+sessionCols+` FROM check_sessions ORDER BY started_at DESC LIMIT $1`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Session
|
||||
for rows.Next() {
|
||||
var s Session
|
||||
if err := rows.Scan(
|
||||
&s.ID, &s.Status, &s.TriggerKind, &s.IsCurrent, &s.CancelRequested, &s.StartedAt,
|
||||
&s.FinishedAt, &s.DurationMs, &s.TotalRawLines, &s.UniqueCandidates, &s.Collapsed,
|
||||
&s.ProgressTotal, &s.ProgressDone, &s.Valid, &s.Invalid, &s.Error); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ListSessionProxies returns working proxies of a session matching the filter,
|
||||
// joined with uptime metadata from proxies. unique_ips is applied by the caller.
|
||||
func (d *DB) ListSessionProxies(ctx context.Context, sessionID int64, f ProxyFilter, withMeta bool) ([]SessionProxy, error) {
|
||||
var b strings.Builder
|
||||
args := []any{sessionID}
|
||||
b.WriteString(`SELECT sp.proxy_id, sp.canonical_url, sp.protocol, sp.host, sp.port,
|
||||
sp.source_name, sp.latency_ms, sp.speed_mbps, sp.country, sp.exit_ip, sp.is_new`)
|
||||
if withMeta {
|
||||
b.WriteString(`, p.first_seen, p.last_alive, p.consecutive_failures`)
|
||||
} else {
|
||||
b.WriteString(`, NULL, NULL, 0`)
|
||||
}
|
||||
b.WriteString(` FROM session_proxies sp`)
|
||||
if withMeta {
|
||||
b.WriteString(` JOIN proxies p ON p.id = sp.proxy_id`)
|
||||
}
|
||||
b.WriteString(` WHERE sp.session_id = $1`)
|
||||
|
||||
if len(f.Protocols) > 0 {
|
||||
args = append(args, f.Protocols)
|
||||
b.WriteString(argf(` AND sp.protocol = ANY($%d)`, len(args)))
|
||||
}
|
||||
if len(f.Countries) > 0 {
|
||||
args = append(args, f.Countries)
|
||||
b.WriteString(argf(` AND sp.country = ANY($%d)`, len(args)))
|
||||
}
|
||||
if len(f.Sources) > 0 {
|
||||
args = append(args, f.Sources)
|
||||
b.WriteString(argf(` AND sp.source_name = ANY($%d)`, len(args)))
|
||||
}
|
||||
if f.MaxLatencyMs != nil {
|
||||
args = append(args, *f.MaxLatencyMs)
|
||||
b.WriteString(argf(` AND sp.latency_ms <= $%d`, len(args)))
|
||||
}
|
||||
if f.MinSpeedMbps != nil {
|
||||
args = append(args, *f.MinSpeedMbps)
|
||||
b.WriteString(argf(` AND sp.speed_mbps >= $%d`, len(args)))
|
||||
}
|
||||
if f.Search != "" {
|
||||
args = append(args, "%"+f.Search+"%")
|
||||
b.WriteString(searchClause(len(args)))
|
||||
}
|
||||
b.WriteString(` ORDER BY sp.speed_mbps DESC, sp.latency_ms ASC`)
|
||||
if f.Limit > 0 {
|
||||
args = append(args, f.Limit)
|
||||
b.WriteString(argf(` LIMIT $%d`, len(args)))
|
||||
}
|
||||
if f.Offset > 0 {
|
||||
args = append(args, f.Offset)
|
||||
b.WriteString(argf(` OFFSET $%d`, len(args)))
|
||||
}
|
||||
|
||||
rows, err := d.pool.Query(ctx, b.String(), args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []SessionProxy
|
||||
for rows.Next() {
|
||||
var sp SessionProxy
|
||||
if err := rows.Scan(&sp.ProxyID, &sp.CanonicalURL, &sp.Protocol, &sp.Host, &sp.Port,
|
||||
&sp.SourceName, &sp.LatencyMs, &sp.SpeedMbps, &sp.Country, &sp.ExitIP, &sp.IsNew,
|
||||
&sp.FirstSeen, &sp.LastAlive, &sp.ConsecutiveFailures); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, sp)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// CountSessionProxies counts working proxies of a session matching the filter
|
||||
// (search only — used for pagination totals of the Proxies tab).
|
||||
func (d *DB) CountSessionProxies(ctx context.Context, sessionID int64, f ProxyFilter) (int, error) {
|
||||
var b strings.Builder
|
||||
args := []any{sessionID}
|
||||
b.WriteString(`SELECT count(*) FROM session_proxies sp WHERE sp.session_id = $1`)
|
||||
if len(f.Protocols) > 0 {
|
||||
args = append(args, f.Protocols)
|
||||
b.WriteString(argf(` AND sp.protocol = ANY($%d)`, len(args)))
|
||||
}
|
||||
if len(f.Countries) > 0 {
|
||||
args = append(args, f.Countries)
|
||||
b.WriteString(argf(` AND sp.country = ANY($%d)`, len(args)))
|
||||
}
|
||||
if len(f.Sources) > 0 {
|
||||
args = append(args, f.Sources)
|
||||
b.WriteString(argf(` AND sp.source_name = ANY($%d)`, len(args)))
|
||||
}
|
||||
if f.MaxLatencyMs != nil {
|
||||
args = append(args, *f.MaxLatencyMs)
|
||||
b.WriteString(argf(` AND sp.latency_ms <= $%d`, len(args)))
|
||||
}
|
||||
if f.MinSpeedMbps != nil {
|
||||
args = append(args, *f.MinSpeedMbps)
|
||||
b.WriteString(argf(` AND sp.speed_mbps >= $%d`, len(args)))
|
||||
}
|
||||
if f.Search != "" {
|
||||
args = append(args, "%"+f.Search+"%")
|
||||
b.WriteString(searchClause(len(args)))
|
||||
}
|
||||
var n int
|
||||
err := d.pool.QueryRow(ctx, b.String(), args...).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// DeleteSessionProxies removes proxies from the current session view (Proxies
|
||||
// tab "delete selected"). Does not touch history.
|
||||
func (d *DB) DeleteSessionProxies(ctx context.Context, sessionID int64, proxyIDs []int64) error {
|
||||
_, err := d.pool.Exec(ctx,
|
||||
`DELETE FROM session_proxies WHERE session_id = $1 AND proxy_id = ANY($2)`,
|
||||
sessionID, proxyIDs)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package db
|
||||
|
||||
import "context"
|
||||
|
||||
// ListSources returns all sources ordered by id.
|
||||
func (d *DB) ListSources(ctx context.Context) ([]Source, error) {
|
||||
rows, err := d.pool.Query(ctx,
|
||||
`SELECT id, name, url, enabled, last_fetched_at, last_line_count, last_error, created_at
|
||||
FROM sources ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanSources(rows)
|
||||
}
|
||||
|
||||
// ListActiveSources returns only enabled sources.
|
||||
func (d *DB) ListActiveSources(ctx context.Context) ([]Source, error) {
|
||||
rows, err := d.pool.Query(ctx,
|
||||
`SELECT id, name, url, enabled, last_fetched_at, last_line_count, last_error, created_at
|
||||
FROM sources WHERE enabled ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanSources(rows)
|
||||
}
|
||||
|
||||
func scanSources(rows interface {
|
||||
Next() bool
|
||||
Scan(...any) error
|
||||
Err() error
|
||||
}) ([]Source, error) {
|
||||
var out []Source
|
||||
for rows.Next() {
|
||||
var s Source
|
||||
if err := rows.Scan(&s.ID, &s.Name, &s.URL, &s.Enabled,
|
||||
&s.LastFetchedAt, &s.LastLineCount, &s.LastError, &s.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// CreateSource inserts a source and returns its id.
|
||||
func (d *DB) CreateSource(ctx context.Context, name, url string, enabled bool) (int64, error) {
|
||||
var id int64
|
||||
err := d.pool.QueryRow(ctx,
|
||||
`INSERT INTO sources (name, url, enabled) VALUES ($1, $2, $3)
|
||||
ON CONFLICT (url) DO UPDATE SET name = EXCLUDED.name, enabled = EXCLUDED.enabled
|
||||
RETURNING id`,
|
||||
name, url, enabled).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
// UpdateSource updates name/url/enabled.
|
||||
func (d *DB) UpdateSource(ctx context.Context, id int64, name, url string, enabled bool) error {
|
||||
_, err := d.pool.Exec(ctx,
|
||||
`UPDATE sources SET name = $2, url = $3, enabled = $4 WHERE id = $1`,
|
||||
id, name, url, enabled)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteSource removes a source.
|
||||
func (d *DB) DeleteSource(ctx context.Context, id int64) error {
|
||||
_, err := d.pool.Exec(ctx, `DELETE FROM sources WHERE id = $1`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateSourceFetchStats records the outcome of the last fetch of a source.
|
||||
func (d *DB) UpdateSourceFetchStats(ctx context.Context, id int64, lineCount int, errStr string) error {
|
||||
_, err := d.pool.Exec(ctx,
|
||||
`UPDATE sources SET last_fetched_at = now(), last_line_count = $2, last_error = $3 WHERE id = $1`,
|
||||
id, lineCount, errStr)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
package db
|
||||
|
||||
import "context"
|
||||
|
||||
// ProtocolStat is per-protocol validity for a session.
|
||||
type ProtocolStat struct {
|
||||
Protocol string `json:"protocol"`
|
||||
Checked int `json:"checked"`
|
||||
Passed int `json:"passed"`
|
||||
Failed int `json:"failed"`
|
||||
}
|
||||
|
||||
// ProtocolStats returns per-protocol counters for a session.
|
||||
func (d *DB) ProtocolStats(ctx context.Context, sessionID int64) ([]ProtocolStat, error) {
|
||||
rows, err := d.pool.Query(ctx,
|
||||
`SELECT protocol, checked, passed, failed FROM session_protocol_stats
|
||||
WHERE session_id = $1 ORDER BY passed DESC`, sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []ProtocolStat
|
||||
for rows.Next() {
|
||||
var s ProtocolStat
|
||||
if err := rows.Scan(&s.Protocol, &s.Checked, &s.Passed, &s.Failed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// LabelCount is a generic label→count pair (country/source breakdowns).
|
||||
type LabelCount struct {
|
||||
Label string `json:"label"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// CountryStats returns the top countries by working-proxy count for a session.
|
||||
func (d *DB) CountryStats(ctx context.Context, sessionID int64, limit int) ([]LabelCount, error) {
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
rows, err := d.pool.Query(ctx,
|
||||
`SELECT COALESCE(NULLIF(country, ''), 'XX') AS c, count(*)
|
||||
FROM session_proxies WHERE session_id = $1
|
||||
GROUP BY c ORDER BY count(*) DESC LIMIT $2`, sessionID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanLabelCounts(rows)
|
||||
}
|
||||
|
||||
// SourceStat is per-source contribution to a session.
|
||||
type SourceStat struct {
|
||||
Source string `json:"source"`
|
||||
RawLines int `json:"raw_lines"`
|
||||
UniqueCandidates int `json:"unique_candidates"`
|
||||
Passed int `json:"passed"`
|
||||
}
|
||||
|
||||
// SourceStats returns per-source stats for a session.
|
||||
func (d *DB) SourceStats(ctx context.Context, sessionID int64) ([]SourceStat, error) {
|
||||
rows, err := d.pool.Query(ctx,
|
||||
`SELECT source_name, raw_lines, unique_candidates, passed FROM session_source_stats
|
||||
WHERE session_id = $1 ORDER BY passed DESC`, sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []SourceStat
|
||||
for rows.Next() {
|
||||
var s SourceStat
|
||||
if err := rows.Scan(&s.Source, &s.RawLines, &s.UniqueCandidates, &s.Passed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// LatencyBuckets returns a fixed-bucket latency histogram for a session.
|
||||
func (d *DB) LatencyBuckets(ctx context.Context, sessionID int64) ([]LabelCount, error) {
|
||||
rows, err := d.pool.Query(ctx,
|
||||
`SELECT bucket, count(*) FROM (
|
||||
SELECT CASE
|
||||
WHEN latency_ms < 100 THEN '0-100'
|
||||
WHEN latency_ms < 250 THEN '100-250'
|
||||
WHEN latency_ms < 500 THEN '250-500'
|
||||
WHEN latency_ms < 1000 THEN '500-1000'
|
||||
ELSE '1000+'
|
||||
END AS bucket,
|
||||
CASE
|
||||
WHEN latency_ms < 100 THEN 0
|
||||
WHEN latency_ms < 250 THEN 1
|
||||
WHEN latency_ms < 500 THEN 2
|
||||
WHEN latency_ms < 1000 THEN 3
|
||||
ELSE 4
|
||||
END AS ord
|
||||
FROM session_proxies WHERE session_id = $1
|
||||
) t GROUP BY bucket, ord ORDER BY ord`, sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanLabelCounts(rows)
|
||||
}
|
||||
|
||||
// SpeedBuckets returns a fixed-bucket speed histogram for a session.
|
||||
func (d *DB) SpeedBuckets(ctx context.Context, sessionID int64) ([]LabelCount, error) {
|
||||
rows, err := d.pool.Query(ctx,
|
||||
`SELECT bucket, count(*) FROM (
|
||||
SELECT CASE
|
||||
WHEN speed_mbps < 3 THEN '0-3'
|
||||
WHEN speed_mbps < 10 THEN '3-10'
|
||||
WHEN speed_mbps < 25 THEN '10-25'
|
||||
WHEN speed_mbps < 50 THEN '25-50'
|
||||
ELSE '50+'
|
||||
END AS bucket,
|
||||
CASE
|
||||
WHEN speed_mbps < 3 THEN 0
|
||||
WHEN speed_mbps < 10 THEN 1
|
||||
WHEN speed_mbps < 25 THEN 2
|
||||
WHEN speed_mbps < 50 THEN 3
|
||||
ELSE 4
|
||||
END AS ord
|
||||
FROM session_proxies WHERE session_id = $1
|
||||
) t GROUP BY bucket, ord ORDER BY ord`, sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanLabelCounts(rows)
|
||||
}
|
||||
|
||||
// LatencyAvgMedian returns the average and median latency for a session.
|
||||
func (d *DB) LatencyAvgMedian(ctx context.Context, sessionID int64) (avg, median float64, err error) {
|
||||
err = d.pool.QueryRow(ctx,
|
||||
`SELECT COALESCE(avg(latency_ms), 0),
|
||||
COALESCE(percentile_cont(0.5) WITHIN GROUP (ORDER BY latency_ms), 0)
|
||||
FROM session_proxies WHERE session_id = $1`, sessionID).Scan(&avg, &median)
|
||||
return avg, median, err
|
||||
}
|
||||
|
||||
// SpeedAvgMedian returns the average and median speed for a session.
|
||||
func (d *DB) SpeedAvgMedian(ctx context.Context, sessionID int64) (avg, median float64, err error) {
|
||||
err = d.pool.QueryRow(ctx,
|
||||
`SELECT COALESCE(avg(speed_mbps), 0),
|
||||
COALESCE(percentile_cont(0.5) WITHIN GROUP (ORDER BY speed_mbps), 0)
|
||||
FROM session_proxies WHERE session_id = $1`, sessionID).Scan(&avg, &median)
|
||||
return avg, median, err
|
||||
}
|
||||
|
||||
// PrevCompletedSessionID returns the id of the completed session immediately
|
||||
// before the given one (for add/drop dynamics). Returns 0 if none.
|
||||
func (d *DB) PrevCompletedSessionID(ctx context.Context, beforeID int64) (int64, error) {
|
||||
var id int64
|
||||
err := d.pool.QueryRow(ctx,
|
||||
`SELECT COALESCE(max(id), 0) FROM check_sessions
|
||||
WHERE status = 'completed' AND id < $1`, beforeID).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
// DynamicsCounts returns how many proxies are new in curSession and how many
|
||||
// present in prevSession dropped out of curSession.
|
||||
func (d *DB) DynamicsCounts(ctx context.Context, prevSession, curSession int64) (added, dropped int, err error) {
|
||||
if err = d.pool.QueryRow(ctx,
|
||||
`SELECT count(*) FROM session_proxies WHERE session_id = $1 AND is_new`,
|
||||
curSession).Scan(&added); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
if prevSession == 0 {
|
||||
return added, 0, nil
|
||||
}
|
||||
err = d.pool.QueryRow(ctx,
|
||||
`SELECT count(*) FROM session_proxies prev
|
||||
WHERE prev.session_id = $1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM session_proxies cur
|
||||
WHERE cur.session_id = $2 AND cur.proxy_id = prev.proxy_id)`,
|
||||
prevSession, curSession).Scan(&dropped)
|
||||
return added, dropped, err
|
||||
}
|
||||
|
||||
// UptimeRow is a working proxy with derived uptime info.
|
||||
type UptimeRow struct {
|
||||
CanonicalURL string `json:"url"`
|
||||
Protocol string `json:"protocol"`
|
||||
Country string `json:"country"`
|
||||
TotalChecks int64 `json:"total_checks"`
|
||||
TotalPasses int64 `json:"total_passes"`
|
||||
UptimePct float64 `json:"uptime_pct"`
|
||||
AliveDays float64 `json:"alive_days"`
|
||||
}
|
||||
|
||||
// TopUptime returns the longest-lived working proxies of the current session.
|
||||
func (d *DB) TopUptime(ctx context.Context, sessionID int64, limit int) ([]UptimeRow, error) {
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
rows, err := d.pool.Query(ctx,
|
||||
`SELECT p.canonical_url, p.protocol, p.last_country, p.total_checks, p.total_passes,
|
||||
CASE WHEN p.total_checks > 0 THEN p.total_passes::float / p.total_checks ELSE 0 END,
|
||||
COALESCE(EXTRACT(EPOCH FROM (now() - p.first_seen)) / 86400.0, 0)
|
||||
FROM session_proxies sp JOIN proxies p ON p.id = sp.proxy_id
|
||||
WHERE sp.session_id = $1
|
||||
ORDER BY p.first_seen ASC LIMIT $2`, sessionID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []UptimeRow
|
||||
for rows.Next() {
|
||||
var u UptimeRow
|
||||
if err := rows.Scan(&u.CanonicalURL, &u.Protocol, &u.Country,
|
||||
&u.TotalChecks, &u.TotalPasses, &u.UptimePct, &u.AliveDays); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// DistinctCountries returns the set of countries present in a session (filter UI).
|
||||
func (d *DB) DistinctCountries(ctx context.Context, sessionID int64) ([]string, error) {
|
||||
rows, err := d.pool.Query(ctx,
|
||||
`SELECT DISTINCT COALESCE(NULLIF(country,''),'XX') FROM session_proxies
|
||||
WHERE session_id = $1 ORDER BY 1`, sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var s string
|
||||
if err := rows.Scan(&s); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func scanLabelCounts(rows interface {
|
||||
Next() bool
|
||||
Scan(...any) error
|
||||
Err() error
|
||||
}) ([]LabelCount, error) {
|
||||
var out []LabelCount
|
||||
for rows.Next() {
|
||||
var lc LabelCount
|
||||
if err := rows.Scan(&lc.Label, &lc.Count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, lc)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
const subCols = `id, token, name, enabled, format, filter_protocols, filter_countries,
|
||||
filter_sources, max_latency_ms, min_speed_mbps, limit_n, unique_ips, unique_ips_metric,
|
||||
sort_by, expires_at, request_count, last_requested_at, created_at`
|
||||
|
||||
func scanSub(row pgx.Row) (*Subscription, error) {
|
||||
var s Subscription
|
||||
err := row.Scan(&s.ID, &s.Token, &s.Name, &s.Enabled, &s.Format,
|
||||
&s.FilterProtocols, &s.FilterCountries, &s.FilterSources,
|
||||
&s.MaxLatencyMs, &s.MinSpeedMbps, &s.LimitN, &s.UniqueIPs, &s.UniqueIPsMetric,
|
||||
&s.SortBy, &s.ExpiresAt, &s.RequestCount, &s.LastRequestedAt, &s.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
// ListSubscriptions returns all subscriptions.
|
||||
func (d *DB) ListSubscriptions(ctx context.Context) ([]Subscription, error) {
|
||||
rows, err := d.pool.Query(ctx, `SELECT `+subCols+` FROM subscriptions ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Subscription
|
||||
for rows.Next() {
|
||||
s, err := scanSub(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetSubscriptionByToken returns a subscription by its public token.
|
||||
func (d *DB) GetSubscriptionByToken(ctx context.Context, token string) (*Subscription, error) {
|
||||
return scanSub(d.pool.QueryRow(ctx, `SELECT `+subCols+` FROM subscriptions WHERE token = $1`, token))
|
||||
}
|
||||
|
||||
// CreateSubscription inserts a subscription and returns it.
|
||||
func (d *DB) CreateSubscription(ctx context.Context, s *Subscription) (*Subscription, error) {
|
||||
row := d.pool.QueryRow(ctx,
|
||||
`INSERT INTO subscriptions
|
||||
(token, name, enabled, format, filter_protocols, filter_countries, filter_sources,
|
||||
max_latency_ms, min_speed_mbps, limit_n, unique_ips, unique_ips_metric, sort_by, expires_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
|
||||
RETURNING `+subCols,
|
||||
s.Token, s.Name, s.Enabled, s.Format, s.FilterProtocols, s.FilterCountries, s.FilterSources,
|
||||
s.MaxLatencyMs, s.MinSpeedMbps, s.LimitN, s.UniqueIPs, s.UniqueIPsMetric, s.SortBy, s.ExpiresAt)
|
||||
return scanSub(row)
|
||||
}
|
||||
|
||||
// UpdateSubscription updates an editable subscription's fields.
|
||||
func (d *DB) UpdateSubscription(ctx context.Context, s *Subscription) (*Subscription, error) {
|
||||
row := d.pool.QueryRow(ctx,
|
||||
`UPDATE subscriptions SET
|
||||
name=$2, enabled=$3, format=$4, filter_protocols=$5, filter_countries=$6, filter_sources=$7,
|
||||
max_latency_ms=$8, min_speed_mbps=$9, limit_n=$10, unique_ips=$11, unique_ips_metric=$12,
|
||||
sort_by=$13, expires_at=$14
|
||||
WHERE id=$1 RETURNING `+subCols,
|
||||
s.ID, s.Name, s.Enabled, s.Format, s.FilterProtocols, s.FilterCountries, s.FilterSources,
|
||||
s.MaxLatencyMs, s.MinSpeedMbps, s.LimitN, s.UniqueIPs, s.UniqueIPsMetric, s.SortBy, s.ExpiresAt)
|
||||
return scanSub(row)
|
||||
}
|
||||
|
||||
// DeleteSubscription removes a subscription.
|
||||
func (d *DB) DeleteSubscription(ctx context.Context, id int64) error {
|
||||
_, err := d.pool.Exec(ctx, `DELETE FROM subscriptions WHERE id = $1`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// TouchSubscription records a subscription hit (request count + timestamp).
|
||||
func (d *DB) TouchSubscription(ctx context.Context, id int64) error {
|
||||
_, err := d.pool.Exec(ctx,
|
||||
`UPDATE subscriptions SET request_count = request_count + 1, last_requested_at = now() WHERE id = $1`, id)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package dialer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
|
||||
"git.qomar.pw/omar/zhguchiy_perchik/internal/dialer/shadowsocks"
|
||||
"git.qomar.pw/omar/zhguchiy_perchik/internal/dialer/trojan"
|
||||
"git.qomar.pw/omar/zhguchiy_perchik/internal/dialer/vless"
|
||||
"git.qomar.pw/omar/zhguchiy_perchik/internal/dialer/vmess"
|
||||
"git.qomar.pw/omar/zhguchiy_perchik/internal/upstream"
|
||||
)
|
||||
|
||||
// Dialer establishes a tunneled TCP connection to dest via the given upstream.
|
||||
type Dialer interface {
|
||||
Dial(ctx context.Context, up *upstream.Upstream, dest string) (net.Conn, error)
|
||||
}
|
||||
|
||||
// Dispatcher selects the appropriate Dialer based on upstream scheme.
|
||||
type Dispatcher struct {
|
||||
ss *shadowsocks.Dialer
|
||||
vless *vless.Dialer
|
||||
trojan *trojan.Dialer
|
||||
vmess *vmess.Dialer
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
func NewDispatcher(log *slog.Logger) *Dispatcher {
|
||||
return &Dispatcher{
|
||||
ss: &shadowsocks.Dialer{Log: log},
|
||||
vless: &vless.Dialer{Log: log},
|
||||
trojan: &trojan.Dialer{Log: log},
|
||||
vmess: &vmess.Dialer{Log: log},
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Dispatcher) Dial(ctx context.Context, up *upstream.Upstream, dest string) (net.Conn, error) {
|
||||
switch up.Scheme {
|
||||
case upstream.SchemeShadowsocks:
|
||||
return d.ss.Dial(ctx, up, dest)
|
||||
case upstream.SchemeVLESS:
|
||||
return d.vless.Dial(ctx, up, dest)
|
||||
case upstream.SchemeTrojan:
|
||||
return d.trojan.Dial(ctx, up, dest)
|
||||
case upstream.SchemeVMess:
|
||||
return d.vmess.Dial(ctx, up, dest)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported protocol: %s", up.Scheme)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package shadowsocks
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// SOCKS5 address types.
|
||||
const (
|
||||
atypIPv4 byte = 0x01
|
||||
atypDomain byte = 0x03
|
||||
atypIPv6 byte = 0x04
|
||||
)
|
||||
|
||||
// encodeAddress builds a SOCKS5-style "[ATYP][ADDR][PORT]" header for the
|
||||
// given "host:port" destination. Numeric IPs become IPv4/IPv6 records;
|
||||
// names become domain records.
|
||||
func encodeAddress(hostPort string) ([]byte, error) {
|
||||
host, portStr, err := net.SplitHostPort(hostPort)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("address: %w", err)
|
||||
}
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("address: bad port: %w", err)
|
||||
}
|
||||
if port <= 0 || port > 65535 {
|
||||
return nil, fmt.Errorf("address: port out of range: %d", port)
|
||||
}
|
||||
|
||||
var buf []byte
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
if v4 := ip.To4(); v4 != nil {
|
||||
buf = make([]byte, 1+4+2)
|
||||
buf[0] = atypIPv4
|
||||
copy(buf[1:5], v4)
|
||||
binary.BigEndian.PutUint16(buf[5:], uint16(port))
|
||||
} else {
|
||||
v6 := ip.To16()
|
||||
buf = make([]byte, 1+16+2)
|
||||
buf[0] = atypIPv6
|
||||
copy(buf[1:17], v6)
|
||||
binary.BigEndian.PutUint16(buf[17:], uint16(port))
|
||||
}
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
if len(host) > 255 {
|
||||
return nil, fmt.Errorf("address: domain too long (%d bytes)", len(host))
|
||||
}
|
||||
buf = make([]byte, 1+1+len(host)+2)
|
||||
buf[0] = atypDomain
|
||||
buf[1] = byte(len(host))
|
||||
copy(buf[2:2+len(host)], host)
|
||||
binary.BigEndian.PutUint16(buf[2+len(host):], uint16(port))
|
||||
return buf, nil
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package shadowsocks
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha1"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
|
||||
"golang.org/x/crypto/chacha20poly1305"
|
||||
"golang.org/x/crypto/hkdf"
|
||||
)
|
||||
|
||||
const (
|
||||
maxPayloadSize = 0x3FFF // 16383 bytes
|
||||
tagLen = 16
|
||||
)
|
||||
|
||||
type aeadReader struct {
|
||||
io.Reader
|
||||
masterKey []byte
|
||||
spec cipherSpec
|
||||
aead cipher.AEAD
|
||||
nonce [12]byte
|
||||
buf []byte
|
||||
payload []byte
|
||||
off int
|
||||
}
|
||||
|
||||
func (r *aeadReader) Read(b []byte) (int, error) {
|
||||
if r.payload != nil && r.off < len(r.payload) {
|
||||
n := copy(b, r.payload[r.off:])
|
||||
r.off += n
|
||||
return n, nil
|
||||
}
|
||||
|
||||
if r.aead == nil {
|
||||
salt := make([]byte, r.spec.ivLen)
|
||||
if _, err := io.ReadFull(r.Reader, salt); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
aead, err := newAEAD(r.spec.name, r.masterKey, salt)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
r.aead = aead
|
||||
r.buf = make([]byte, maxPayloadSize+tagLen)
|
||||
}
|
||||
|
||||
// 1. Read and decrypt length
|
||||
lenBuf := r.buf[:2+tagLen]
|
||||
if _, err := io.ReadFull(r.Reader, lenBuf); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
realLenBuf, err := r.aead.Open(lenBuf[:0], r.nonce[:], lenBuf, nil)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("aead: decrypt length: %w", err)
|
||||
}
|
||||
incrementNonce(r.nonce[:])
|
||||
payloadLen := int(binary.BigEndian.Uint16(realLenBuf)) & maxPayloadSize
|
||||
|
||||
// 2. Read and decrypt payload
|
||||
payloadBuf := r.buf[:payloadLen+tagLen]
|
||||
if _, err := io.ReadFull(r.Reader, payloadBuf); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
r.payload, err = r.aead.Open(payloadBuf[:0], r.nonce[:], payloadBuf, nil)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("aead: decrypt payload: %w", err)
|
||||
}
|
||||
incrementNonce(r.nonce[:])
|
||||
r.off = 0
|
||||
|
||||
n := copy(b, r.payload)
|
||||
r.off = n
|
||||
return n, nil
|
||||
}
|
||||
|
||||
type aeadWriter struct {
|
||||
io.Writer
|
||||
masterKey []byte
|
||||
spec cipherSpec
|
||||
aead cipher.AEAD
|
||||
nonce [12]byte
|
||||
buf []byte
|
||||
}
|
||||
|
||||
func (w *aeadWriter) Write(b []byte) (int, error) {
|
||||
if w.aead == nil {
|
||||
salt := make([]byte, w.spec.ivLen)
|
||||
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if _, err := w.Writer.Write(salt); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
aead, err := newAEAD(w.spec.name, w.masterKey, salt)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
w.aead = aead
|
||||
w.buf = make([]byte, 2+tagLen+maxPayloadSize+tagLen)
|
||||
}
|
||||
|
||||
n := 0
|
||||
for len(b) > 0 {
|
||||
chunkLen := len(b)
|
||||
if chunkLen > maxPayloadSize {
|
||||
chunkLen = maxPayloadSize
|
||||
}
|
||||
|
||||
// Encrypt length
|
||||
binary.BigEndian.PutUint16(w.buf[:2], uint16(chunkLen))
|
||||
w.aead.Seal(w.buf[:0], w.nonce[:], w.buf[:2], nil)
|
||||
incrementNonce(w.nonce[:])
|
||||
|
||||
// Encrypt payload
|
||||
w.aead.Seal(w.buf[2+tagLen:2+tagLen], w.nonce[:], b[:chunkLen], nil)
|
||||
incrementNonce(w.nonce[:])
|
||||
|
||||
if _, err := w.Writer.Write(w.buf[:2+tagLen+chunkLen+tagLen]); err != nil {
|
||||
return n, err
|
||||
}
|
||||
|
||||
n += chunkLen
|
||||
b = b[chunkLen:]
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func newAEAD(method string, masterKey, salt []byte) (cipher.AEAD, error) {
|
||||
subkey := make([]byte, len(masterKey))
|
||||
h := hkdf.New(sha1.New, masterKey, salt, []byte("ss-subkey"))
|
||||
if _, err := io.ReadFull(h, subkey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switch method {
|
||||
case "aes-128-gcm", "aes-192-gcm", "aes-256-gcm":
|
||||
block, err := aes.NewCipher(subkey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cipher.NewGCM(block)
|
||||
case "chacha20-ietf-poly1305":
|
||||
return chacha20poly1305.New(subkey)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported aead method %q", method)
|
||||
}
|
||||
}
|
||||
|
||||
func incrementNonce(nonce []byte) {
|
||||
for i := range nonce {
|
||||
nonce[i]++
|
||||
if nonce[i] != 0 {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newAEADConn(c net.Conn, spec cipherSpec, key []byte) (*Conn, error) {
|
||||
return &Conn{
|
||||
Conn: c,
|
||||
r: &aeadReader{Reader: c, masterKey: key, spec: spec},
|
||||
w: &aeadWriter{Writer: c, masterKey: key, spec: spec},
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package shadowsocks
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type kind int
|
||||
|
||||
const (
|
||||
kindStream kind = iota + 1
|
||||
kindAEAD
|
||||
)
|
||||
|
||||
type cipherSpec struct {
|
||||
name string
|
||||
kind kind
|
||||
keyLen int
|
||||
// For stream ciphers: ivLen == block size (AES = 16).
|
||||
// For AEAD: ivLen is the salt length, equal to keyLen by SS-spec.
|
||||
ivLen int
|
||||
}
|
||||
|
||||
var ciphers = map[string]cipherSpec{
|
||||
// Stream (legacy)
|
||||
"aes-128-cfb": {name: "aes-128-cfb", kind: kindStream, keyLen: 16, ivLen: 16},
|
||||
"aes-192-cfb": {name: "aes-192-cfb", kind: kindStream, keyLen: 24, ivLen: 16},
|
||||
"aes-256-cfb": {name: "aes-256-cfb", kind: kindStream, keyLen: 32, ivLen: 16},
|
||||
|
||||
// AEAD (SIP004)
|
||||
"aes-128-gcm": {name: "aes-128-gcm", kind: kindAEAD, keyLen: 16, ivLen: 16},
|
||||
"aes-192-gcm": {name: "aes-192-gcm", kind: kindAEAD, keyLen: 24, ivLen: 24},
|
||||
"aes-256-gcm": {name: "aes-256-gcm", kind: kindAEAD, keyLen: 32, ivLen: 32},
|
||||
"chacha20-ietf-poly1305": {name: "chacha20-ietf-poly1305", kind: kindAEAD, keyLen: 32, ivLen: 32},
|
||||
}
|
||||
|
||||
func lookupCipher(method string) (cipherSpec, error) {
|
||||
m := strings.ToLower(strings.TrimSpace(method))
|
||||
c, ok := ciphers[m]
|
||||
if !ok {
|
||||
return cipherSpec{}, fmt.Errorf("unsupported ss method %q", method)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// deriveMasterKey implements OpenSSL's EVP_BytesToKey with MD5 and no salt:
|
||||
// key = MD5(password) || MD5(MD5(password) || password) || ...
|
||||
// truncated to keyLen. This is the legacy Shadowsocks key-derivation
|
||||
// expected by all SS servers.
|
||||
func deriveMasterKey(password string, keyLen int) []byte {
|
||||
out := make([]byte, 0, keyLen)
|
||||
var prev []byte
|
||||
for len(out) < keyLen {
|
||||
h := md5.New()
|
||||
h.Write(prev)
|
||||
h.Write([]byte(password))
|
||||
prev = h.Sum(nil)
|
||||
out = append(out, prev...)
|
||||
}
|
||||
return out[:keyLen]
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package shadowsocks
|
||||
|
||||
import (
|
||||
"net"
|
||||
)
|
||||
|
||||
type Conn struct {
|
||||
net.Conn
|
||||
r Reader
|
||||
w Writer
|
||||
}
|
||||
|
||||
type Reader interface {
|
||||
Read(b []byte) (int, error)
|
||||
}
|
||||
|
||||
type Writer interface {
|
||||
Write(b []byte) (int, error)
|
||||
}
|
||||
|
||||
func NewConn(c net.Conn, spec cipherSpec, key []byte) (*Conn, error) {
|
||||
if spec.kind == kindAEAD {
|
||||
return newAEADConn(c, spec, key)
|
||||
}
|
||||
return newStreamConn(c, spec, key)
|
||||
}
|
||||
|
||||
func (c *Conn) Read(b []byte) (int, error) {
|
||||
return c.r.Read(b)
|
||||
}
|
||||
|
||||
func (c *Conn) Write(b []byte) (int, error) {
|
||||
return c.w.Write(b)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package shadowsocks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"git.qomar.pw/omar/zhguchiy_perchik/internal/upstream"
|
||||
)
|
||||
|
||||
type Dialer struct {
|
||||
DialTimeout time.Duration
|
||||
Log *slog.Logger
|
||||
}
|
||||
|
||||
func (d *Dialer) Dial(ctx context.Context, up *upstream.Upstream, dest string) (net.Conn, error) {
|
||||
if up.SS == nil {
|
||||
return nil, fmt.Errorf("shadowsocks: missing config")
|
||||
}
|
||||
|
||||
if d.Log != nil {
|
||||
d.Log.Debug("shadowsocks dial", "upstream", up.Address(), "dest", dest, "method", up.SS.Method)
|
||||
}
|
||||
|
||||
timeout := d.DialTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = 10 * time.Second
|
||||
}
|
||||
|
||||
// 1. Connect to upstream
|
||||
dialer := net.Dialer{Timeout: timeout}
|
||||
c, err := dialer.DialContext(ctx, "tcp", up.Address())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("shadowsocks: connect to upstream: %w", err)
|
||||
}
|
||||
|
||||
if d.Log != nil {
|
||||
d.Log.Debug("shadowsocks connected", "upstream", up.Address())
|
||||
}
|
||||
|
||||
// 2. Prepare crypto
|
||||
spec, err := lookupCipher(up.SS.Method)
|
||||
if err != nil {
|
||||
c.Close()
|
||||
return nil, err
|
||||
}
|
||||
key := deriveMasterKey(up.SS.Password, spec.keyLen)
|
||||
|
||||
// 3. Wrap connection with encryption
|
||||
conn, err := NewConn(c, spec, key)
|
||||
if err != nil {
|
||||
c.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 4. Send Shadowsocks request header (address + port)
|
||||
header, err := upstream.EncodeAddress(dest)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if d.Log != nil {
|
||||
d.Log.Debug("shadowsocks sending header", "dest", dest)
|
||||
}
|
||||
|
||||
if _, err := conn.Write(header); err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("shadowsocks: send header: %w", err)
|
||||
}
|
||||
|
||||
return conn, nil
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package shadowsocks
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"io"
|
||||
"net"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAddressEncoding(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
out []byte
|
||||
}{
|
||||
{"1.2.3.4:80", []byte{atypIPv4, 1, 2, 3, 4, 0, 80}},
|
||||
{"example.com:443", append([]byte{atypDomain, 11}, append([]byte("example.com"), 1, 187)...)},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
got, err := encodeAddress(tc.in)
|
||||
if err != nil {
|
||||
t.Errorf("encodeAddress(%q) error: %v", tc.in, err)
|
||||
continue
|
||||
}
|
||||
if !bytes.Equal(got, tc.out) {
|
||||
t.Errorf("encodeAddress(%q) = %x, want %x", tc.in, got, tc.out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamCipherRoundtrip(t *testing.T) {
|
||||
password := "test-password"
|
||||
method := "aes-256-cfb"
|
||||
spec, _ := lookupCipher(method)
|
||||
key := deriveMasterKey(password, spec.keyLen)
|
||||
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer l.Close()
|
||||
|
||||
done := make(chan bool)
|
||||
data := []byte("hello world")
|
||||
|
||||
go func() {
|
||||
conn, err := l.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
ssConn, err := NewConn(conn, spec, key)
|
||||
if err != nil {
|
||||
t.Errorf("server NewConn error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
buf := make([]byte, len(data))
|
||||
if _, err := io.ReadFull(ssConn, buf); err != nil {
|
||||
t.Errorf("server read error: %v", err)
|
||||
return
|
||||
}
|
||||
if !bytes.Equal(buf, data) {
|
||||
t.Errorf("server got %q, want %q", buf, data)
|
||||
}
|
||||
|
||||
if _, err := ssConn.Write(buf); err != nil {
|
||||
t.Errorf("server write error: %v", err)
|
||||
}
|
||||
done <- true
|
||||
}()
|
||||
|
||||
conn, err := net.Dial("tcp", l.Addr().String())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
ssConn, err := NewConn(conn, spec, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := ssConn.Write(data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
buf := make([]byte, len(data))
|
||||
if _, err := io.ReadFull(ssConn, buf); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(buf, data) {
|
||||
t.Errorf("client got %q, want %q", buf, data)
|
||||
}
|
||||
|
||||
<-done
|
||||
}
|
||||
|
||||
func TestAEADCipherRoundtrip(t *testing.T) {
|
||||
methods := []string{"aes-256-gcm", "chacha20-ietf-poly1305"}
|
||||
for _, method := range methods {
|
||||
t.Run(method, func(t *testing.T) {
|
||||
password := "test-password"
|
||||
spec, _ := lookupCipher(method)
|
||||
key := deriveMasterKey(password, spec.keyLen)
|
||||
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer l.Close()
|
||||
|
||||
done := make(chan bool)
|
||||
data := make([]byte, 20000) // Test larger than maxPayloadSize
|
||||
rand.Read(data)
|
||||
|
||||
go func() {
|
||||
conn, err := l.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
ssConn, err := NewConn(conn, spec, key)
|
||||
if err != nil {
|
||||
t.Errorf("server NewConn error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
buf := make([]byte, len(data))
|
||||
if _, err := io.ReadFull(ssConn, buf); err != nil {
|
||||
t.Errorf("server read error: %v", err)
|
||||
return
|
||||
}
|
||||
if !bytes.Equal(buf, data) {
|
||||
t.Errorf("server got data mismatch")
|
||||
}
|
||||
|
||||
if _, err := ssConn.Write(buf); err != nil {
|
||||
t.Errorf("server write error: %v", err)
|
||||
}
|
||||
done <- true
|
||||
}()
|
||||
|
||||
conn, err := net.Dial("tcp", l.Addr().String())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
ssConn, err := NewConn(conn, spec, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := ssConn.Write(data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
buf := make([]byte, len(data))
|
||||
if _, err := io.ReadFull(ssConn, buf); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(buf, data) {
|
||||
t.Errorf("client got data mismatch")
|
||||
}
|
||||
|
||||
<-done
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package shadowsocks
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"io"
|
||||
"net"
|
||||
)
|
||||
|
||||
type streamReader struct {
|
||||
io.Reader
|
||||
key []byte
|
||||
spec cipherSpec
|
||||
iv []byte
|
||||
dec cipher.Stream
|
||||
}
|
||||
|
||||
func (r *streamReader) Read(b []byte) (int, error) {
|
||||
if r.dec == nil {
|
||||
iv := make([]byte, r.spec.ivLen)
|
||||
if _, err := io.ReadFull(r.Reader, iv); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
r.iv = iv
|
||||
block, err := aes.NewCipher(r.key)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
r.dec = cipher.NewCFBDecrypter(block, r.iv)
|
||||
}
|
||||
n, err := r.Reader.Read(b)
|
||||
if n > 0 {
|
||||
r.dec.XORKeyStream(b[:n], b[:n])
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
type streamWriter struct {
|
||||
io.Writer
|
||||
key []byte
|
||||
spec cipherSpec
|
||||
enc cipher.Stream
|
||||
}
|
||||
|
||||
func (w *streamWriter) Write(b []byte) (int, error) {
|
||||
if w.enc == nil {
|
||||
iv := make([]byte, w.spec.ivLen)
|
||||
if _, err := io.ReadFull(rand.Reader, iv); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if _, err := w.Writer.Write(iv); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
block, err := aes.NewCipher(w.key)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
w.enc = cipher.NewCFBEncrypter(block, iv)
|
||||
}
|
||||
buf := make([]byte, len(b))
|
||||
w.enc.XORKeyStream(buf, b)
|
||||
return w.Writer.Write(buf)
|
||||
}
|
||||
|
||||
func newStreamConn(c net.Conn, spec cipherSpec, key []byte) (*Conn, error) {
|
||||
return &Conn{
|
||||
Conn: c,
|
||||
r: &streamReader{Reader: c, key: key, spec: spec},
|
||||
w: &streamWriter{Writer: c, key: key, spec: spec},
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/http2"
|
||||
)
|
||||
|
||||
type GRPCConn struct {
|
||||
response *http.Response
|
||||
request *http.Request
|
||||
rt http.RoundTripper // *http2.Transport or *http2.ClientConn
|
||||
writer *io.PipeWriter
|
||||
once sync.Once
|
||||
closed bool
|
||||
err error
|
||||
remain int
|
||||
br *bufio.Reader
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (g *GRPCConn) initRequest() {
|
||||
response, err := g.rt.RoundTrip(g.request)
|
||||
if err != nil {
|
||||
g.err = err
|
||||
g.writer.Close()
|
||||
return
|
||||
}
|
||||
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if !g.closed {
|
||||
g.response = response
|
||||
g.br = bufio.NewReader(response.Body)
|
||||
} else {
|
||||
response.Body.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (g *GRPCConn) Read(b []byte) (int, error) {
|
||||
g.once.Do(g.initRequest)
|
||||
if g.err != nil {
|
||||
return 0, g.err
|
||||
}
|
||||
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
if g.remain > 0 {
|
||||
size := g.remain
|
||||
if len(b) < size {
|
||||
size = len(b)
|
||||
}
|
||||
n, err := io.ReadFull(g.br, b[:size])
|
||||
g.remain -= n
|
||||
return n, err
|
||||
}
|
||||
|
||||
if g.response == nil {
|
||||
return 0, io.ErrClosedPipe
|
||||
}
|
||||
|
||||
// Read gRPC frame header: 1 byte compressed flag + 4 bytes length
|
||||
header := make([]byte, 5)
|
||||
if _, err := io.ReadFull(g.br, header); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// Skip protobuf field 1 tag byte (0x0A = field 1, wire type 2)
|
||||
if _, err := g.br.ReadByte(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// Read the actual payload length as varint
|
||||
uLen, err := binary.ReadUvarint(g.br)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
size := int(uLen)
|
||||
n := size
|
||||
if len(b) < size {
|
||||
n = len(b)
|
||||
}
|
||||
|
||||
read, err := io.ReadFull(g.br, b[:n])
|
||||
if read < size {
|
||||
g.remain = size - read
|
||||
}
|
||||
return read, err
|
||||
}
|
||||
|
||||
func (g *GRPCConn) Write(b []byte) (int, error) {
|
||||
// Protobuf field tag 0x0A (field 1, wire type 2 = length-delimited) + varint length
|
||||
protoHeader := make([]byte, 1+binary.MaxVarintLen64)
|
||||
protoHeader[0] = 0x0A
|
||||
varintSize := binary.PutUvarint(protoHeader[1:], uint64(len(b)))
|
||||
|
||||
payloadLen := 1 + varintSize + len(b)
|
||||
|
||||
// gRPC frame header: 1 byte compressed (0) + 4 bytes big-endian length
|
||||
grpcHeader := make([]byte, 5)
|
||||
binary.BigEndian.PutUint32(grpcHeader[1:5], uint32(payloadLen))
|
||||
|
||||
buf := append(grpcHeader, protoHeader[:1+varintSize]...)
|
||||
buf = append(buf, b...)
|
||||
|
||||
_, err := g.writer.Write(buf)
|
||||
return len(b), err
|
||||
}
|
||||
|
||||
func (g *GRPCConn) Close() error {
|
||||
g.mu.Lock()
|
||||
g.closed = true
|
||||
if g.response != nil {
|
||||
g.response.Body.Close()
|
||||
}
|
||||
g.mu.Unlock()
|
||||
return g.writer.Close()
|
||||
}
|
||||
|
||||
func (g *GRPCConn) LocalAddr() net.Addr { return &net.TCPAddr{IP: net.IPv4zero, Port: 0} }
|
||||
func (g *GRPCConn) RemoteAddr() net.Addr { return &net.TCPAddr{IP: net.IPv4zero, Port: 0} }
|
||||
func (g *GRPCConn) SetDeadline(t time.Time) error { return nil }
|
||||
func (g *GRPCConn) SetReadDeadline(t time.Time) error { return nil }
|
||||
func (g *GRPCConn) SetWriteDeadline(t time.Time) error { return nil }
|
||||
|
||||
// DialGRPC establishes a gRPC tunnel, creating a new TLS connection internally.
|
||||
// Used for standard TLS (non-Reality) transports.
|
||||
func DialGRPC(ctx context.Context, addr string, serviceName string, tlsConfig *tls.Config) (net.Conn, error) {
|
||||
if serviceName == "" {
|
||||
serviceName = "GunService"
|
||||
}
|
||||
|
||||
t := &http2.Transport{
|
||||
TLSClientConfig: tlsConfig,
|
||||
AllowHTTP: false,
|
||||
DisableCompression: true,
|
||||
}
|
||||
|
||||
reader, writer := io.Pipe()
|
||||
conn := &GRPCConn{
|
||||
request: newGRPCRequest(addr, serviceName, reader),
|
||||
rt: t,
|
||||
writer: writer,
|
||||
}
|
||||
go conn.once.Do(conn.initRequest)
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// DialGRPCOverConn establishes a gRPC tunnel over an already-established connection.
|
||||
// The conn must already be at the application layer (e.g., after a Reality or TLS handshake).
|
||||
// http2.Transport.NewClientConn skips the ALPN check for non-*tls.Conn types,
|
||||
// so passing a *utls.UConn from a Reality handshake works correctly.
|
||||
func DialGRPCOverConn(ctx context.Context, conn net.Conn, addr string, serviceName string) (net.Conn, error) {
|
||||
if serviceName == "" {
|
||||
serviceName = "GunService"
|
||||
}
|
||||
|
||||
t := &http2.Transport{
|
||||
AllowHTTP: false,
|
||||
DisableCompression: true,
|
||||
}
|
||||
|
||||
h2conn, err := t.NewClientConn(conn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("grpc: h2 client conn: %w", err)
|
||||
}
|
||||
|
||||
reader, writer := io.Pipe()
|
||||
grpcConn := &GRPCConn{
|
||||
request: newGRPCRequest(addr, serviceName, reader),
|
||||
rt: h2conn,
|
||||
writer: writer,
|
||||
}
|
||||
go grpcConn.once.Do(grpcConn.initRequest)
|
||||
return grpcConn, nil
|
||||
}
|
||||
|
||||
func newGRPCRequest(addr, serviceName string, body io.ReadCloser) *http.Request {
|
||||
return &http.Request{
|
||||
Method: http.MethodPost,
|
||||
Body: body,
|
||||
URL: &url.URL{
|
||||
Scheme: "https",
|
||||
Host: addr,
|
||||
Path: fmt.Sprintf("/%s/Tun", serviceName),
|
||||
},
|
||||
Proto: "HTTP/2",
|
||||
ProtoMajor: 2,
|
||||
ProtoMinor: 0,
|
||||
Header: http.Header{
|
||||
"Content-Type": []string{"application/grpc"},
|
||||
"User-Agent": []string{"grpc-go/1.36.0"},
|
||||
"TE": []string{"trailers"},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"context"
|
||||
cryptotls "crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
type WSConn struct {
|
||||
*websocket.Conn
|
||||
reader io.Reader
|
||||
}
|
||||
|
||||
func (c *WSConn) Read(b []byte) (int, error) {
|
||||
for {
|
||||
if c.reader == nil {
|
||||
_, r, err := c.Conn.NextReader()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
c.reader = r
|
||||
}
|
||||
n, err := c.reader.Read(b)
|
||||
if err == io.EOF {
|
||||
c.reader = nil
|
||||
if n > 0 {
|
||||
return n, nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
}
|
||||
|
||||
func (c *WSConn) Write(b []byte) (int, error) {
|
||||
if err := c.Conn.WriteMessage(websocket.BinaryMessage, b); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(b), nil
|
||||
}
|
||||
|
||||
func (c *WSConn) SetDeadline(t time.Time) error {
|
||||
if err := c.Conn.SetReadDeadline(t); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Conn.SetWriteDeadline(t)
|
||||
}
|
||||
|
||||
func DialWS(ctx context.Context, network, addr string, path string, host string, tls bool) (net.Conn, error) {
|
||||
scheme := "ws"
|
||||
if tls {
|
||||
scheme = "wss"
|
||||
}
|
||||
u := url.URL{Scheme: scheme, Host: addr, Path: path}
|
||||
|
||||
dialer := &websocket.Dialer{
|
||||
HandshakeTimeout: 10 * time.Second,
|
||||
TLSClientConfig: &cryptotls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
|
||||
header := http.Header{}
|
||||
if host != "" {
|
||||
header.Set("Host", host)
|
||||
}
|
||||
|
||||
c, _, err := dialer.DialContext(ctx, u.String(), header)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ws dial: %w", err)
|
||||
}
|
||||
|
||||
return &WSConn{Conn: c}, nil
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package trojan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"git.qomar.pw/omar/zhguchiy_perchik/internal/dialer/transport"
|
||||
"git.qomar.pw/omar/zhguchiy_perchik/internal/upstream"
|
||||
)
|
||||
|
||||
type Dialer struct {
|
||||
DialTimeout time.Duration
|
||||
Log *slog.Logger
|
||||
}
|
||||
|
||||
func (d *Dialer) Dial(ctx context.Context, up *upstream.Upstream, dest string) (net.Conn, error) {
|
||||
if up.Trojan == nil {
|
||||
return nil, fmt.Errorf("trojan: missing config")
|
||||
}
|
||||
|
||||
timeout := d.DialTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = 10 * time.Second
|
||||
}
|
||||
|
||||
var conn net.Conn
|
||||
var err error
|
||||
|
||||
// 1. Establish underlay connection
|
||||
switch up.Transport {
|
||||
case "ws":
|
||||
sni := up.Trojan.SNI
|
||||
if sni == "" {
|
||||
sni = up.Host
|
||||
}
|
||||
conn, err = transport.DialWS(ctx, "tcp", up.Address(), up.Path, sni, up.Trojan.Security == "tls")
|
||||
case "grpc":
|
||||
tlsConfig := &tls.Config{
|
||||
ServerName: up.Trojan.SNI,
|
||||
InsecureSkipVerify: true,
|
||||
}
|
||||
conn, err = transport.DialGRPC(ctx, up.Address(), up.ServiceName, tlsConfig)
|
||||
default:
|
||||
dialer := net.Dialer{Timeout: timeout}
|
||||
conn, err = dialer.DialContext(ctx, "tcp", up.Address())
|
||||
if err == nil && up.Trojan.Security != "none" {
|
||||
tlsConfig := &tls.Config{
|
||||
ServerName: up.Trojan.SNI,
|
||||
InsecureSkipVerify: true,
|
||||
}
|
||||
tlsConn := tls.Client(conn, tlsConfig)
|
||||
if err = tlsConn.HandshakeContext(ctx); err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("trojan: tls handshake: %w", err)
|
||||
}
|
||||
conn = tlsConn
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("trojan: connect: %w", err)
|
||||
}
|
||||
|
||||
// 2. Send Trojan header
|
||||
// password hash (56 bytes) + CRLF + command (1 byte) + address + CRLF
|
||||
h := sha256.New224()
|
||||
h.Write([]byte(up.Trojan.Password))
|
||||
pwHash := hex.EncodeToString(h.Sum(nil))
|
||||
|
||||
headerPrefix := fmt.Sprintf("%s\r\n\x01", pwHash)
|
||||
|
||||
addrBuf, err := upstream.EncodeAddress(dest)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, err := conn.Write([]byte(headerPrefix)); err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
if _, err := conn.Write(addrBuf); err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
if _, err := conn.Write([]byte("\r\n")); err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return conn, nil
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
package vless
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
)
|
||||
|
||||
const Version byte = 0
|
||||
|
||||
const (
|
||||
CommandTCP byte = 1
|
||||
CommandUDP byte = 2
|
||||
CommandMux byte = 3
|
||||
)
|
||||
|
||||
const (
|
||||
AtypIPv4 byte = 1
|
||||
AtypDomainName byte = 2
|
||||
AtypIPv6 byte = 3
|
||||
)
|
||||
|
||||
var (
|
||||
TlsApplicationDataStart = []byte{0x17, 0x03, 0x03}
|
||||
)
|
||||
|
||||
const (
|
||||
visionReadTimeout = 2 * time.Second
|
||||
maxReadTimeoutCount = 5
|
||||
)
|
||||
|
||||
type Conn struct {
|
||||
net.Conn
|
||||
uuid uuid.UUID
|
||||
flow string
|
||||
dest string
|
||||
received bool
|
||||
sent bool
|
||||
visionReadActive bool
|
||||
visionWriteActive bool
|
||||
visionUUID []byte
|
||||
readBuffer []byte
|
||||
enableXtls bool
|
||||
tlsAppDataCount int
|
||||
tlsFinishedSent bool
|
||||
readTimeoutCount int
|
||||
}
|
||||
|
||||
func NewConn(conn net.Conn, uuidStr string, flow string, dest string) (*Conn, error) {
|
||||
uid, err := uuid.FromString(uuidStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c := &Conn{
|
||||
Conn: conn,
|
||||
uuid: uid,
|
||||
flow: flow,
|
||||
dest: dest,
|
||||
}
|
||||
|
||||
if flow == "xtls-rprx-vision" {
|
||||
c.visionReadActive = true
|
||||
c.visionWriteActive = true
|
||||
c.visionUUID = uid.Bytes()
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *Conn) Read(b []byte) (int, error) {
|
||||
if !c.received {
|
||||
if err := c.recvResponse(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
c.received = true
|
||||
}
|
||||
|
||||
if len(c.readBuffer) > 0 {
|
||||
n := copy(b, c.readBuffer)
|
||||
c.readBuffer = c.readBuffer[n:]
|
||||
return n, nil
|
||||
}
|
||||
|
||||
if c.visionReadActive {
|
||||
return c.readVisionPadded(b)
|
||||
}
|
||||
|
||||
return c.Conn.Read(b)
|
||||
}
|
||||
|
||||
func (c *Conn) Write(b []byte) (int, error) {
|
||||
if !c.sent {
|
||||
if len(b) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
if err := c.sendRequest(b); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
c.sent = true
|
||||
return len(b), nil
|
||||
}
|
||||
|
||||
if c.visionWriteActive {
|
||||
if len(b) >= 3 && bytes.Equal(b[:3], TlsApplicationDataStart) {
|
||||
c.tlsAppDataCount++
|
||||
if c.tlsAppDataCount == 1 {
|
||||
c.tlsFinishedSent = true
|
||||
return c.writeVisionPadded(b, CommandPaddingContinue)
|
||||
}
|
||||
c.visionWriteActive = false
|
||||
c.enableXtls = true
|
||||
return c.writeVisionPadded(b, CommandPaddingDirect)
|
||||
}
|
||||
return c.writeVisionPadded(b, CommandPaddingContinue)
|
||||
}
|
||||
|
||||
return c.Conn.Write(b)
|
||||
}
|
||||
|
||||
func (c *Conn) sendRequest(payload []byte) error {
|
||||
buf := &bytes.Buffer{}
|
||||
|
||||
buf.WriteByte(Version)
|
||||
buf.Write(c.uuid.Bytes())
|
||||
|
||||
if c.flow != "" && c.flow != "none" {
|
||||
addonBuf := &bytes.Buffer{}
|
||||
flowBytes := []byte(c.flow)
|
||||
addonBuf.WriteByte(0x0A)
|
||||
addonBuf.WriteByte(byte(len(flowBytes)))
|
||||
addonBuf.Write(flowBytes)
|
||||
|
||||
buf.WriteByte(byte(addonBuf.Len()))
|
||||
buf.Write(addonBuf.Bytes())
|
||||
} else {
|
||||
buf.WriteByte(0)
|
||||
}
|
||||
|
||||
buf.WriteByte(CommandTCP)
|
||||
|
||||
host, portStr, err := net.SplitHostPort(c.dest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
port, _ := strconv.Atoi(portStr)
|
||||
binary.Write(buf, binary.BigEndian, uint16(port))
|
||||
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
if v4 := ip.To4(); v4 != nil {
|
||||
buf.WriteByte(AtypIPv4)
|
||||
buf.Write(v4)
|
||||
} else {
|
||||
buf.WriteByte(AtypIPv6)
|
||||
buf.Write(ip.To16())
|
||||
}
|
||||
} else {
|
||||
buf.WriteByte(AtypDomainName)
|
||||
buf.WriteByte(byte(len(host)))
|
||||
buf.Write([]byte(host))
|
||||
}
|
||||
|
||||
if c.visionWriteActive && len(payload) > 0 {
|
||||
paddedPayload := ApplyVisionPaddingWithCmd(payload, c.visionUUID, CommandPaddingContinue)
|
||||
buf.Write(paddedPayload)
|
||||
c.visionUUID = nil
|
||||
} else {
|
||||
buf.Write(payload)
|
||||
}
|
||||
|
||||
_, err = c.Conn.Write(buf.Bytes())
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Conn) recvResponse() error {
|
||||
header := make([]byte, 2)
|
||||
_, err := io.ReadFull(c.Conn, header)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if header[0] != Version {
|
||||
return errors.New("invalid VLESS response version")
|
||||
}
|
||||
|
||||
addonLen := int(header[1])
|
||||
if addonLen > 0 {
|
||||
addon := make([]byte, addonLen)
|
||||
_, err = io.ReadFull(c.Conn, addon)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Conn) writeVisionPadded(b []byte, cmd byte) (int, error) {
|
||||
padded := ApplyVisionPaddingWithCmd(b, c.visionUUID, cmd)
|
||||
if c.visionUUID != nil {
|
||||
c.visionUUID = nil
|
||||
}
|
||||
_, err := c.Conn.Write(padded)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(b), nil
|
||||
}
|
||||
|
||||
func (c *Conn) readVisionPadded(b []byte) (int, error) {
|
||||
content, cmd, err := ReadVisionPaddedWithTimeout(c.Conn, visionReadTimeout)
|
||||
|
||||
if err == ErrVisionTimeout {
|
||||
c.readTimeoutCount++
|
||||
if c.readTimeoutCount >= maxReadTimeoutCount || c.enableXtls {
|
||||
c.visionReadActive = false
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
c.readTimeoutCount = 0
|
||||
|
||||
if err == ErrNotVisionFrame {
|
||||
c.visionReadActive = false
|
||||
if content != nil {
|
||||
n := copy(b, content)
|
||||
if n < len(content) {
|
||||
c.readBuffer = content[n:]
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if cmd == CommandPaddingEnd || cmd == CommandPaddingDirect {
|
||||
c.visionReadActive = false
|
||||
} else if c.enableXtls && len(content) >= 3 && bytes.Equal(content[:3], TlsApplicationDataStart) {
|
||||
c.visionReadActive = false
|
||||
}
|
||||
|
||||
n := copy(b, content)
|
||||
if n < len(content) {
|
||||
c.readBuffer = content[n:]
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package vless
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"git.qomar.pw/omar/zhguchiy_perchik/internal/dialer/transport"
|
||||
"git.qomar.pw/omar/zhguchiy_perchik/internal/upstream"
|
||||
)
|
||||
|
||||
type Dialer struct {
|
||||
DialTimeout time.Duration
|
||||
Log *slog.Logger
|
||||
}
|
||||
|
||||
func (d *Dialer) Dial(ctx context.Context, up *upstream.Upstream, dest string) (net.Conn, error) {
|
||||
if up.VLESS == nil {
|
||||
return nil, fmt.Errorf("vless: missing config")
|
||||
}
|
||||
|
||||
if d.Log != nil {
|
||||
d.Log.Debug("vless dial", "upstream", up.Address(), "dest", dest,
|
||||
"security", up.VLESS.Security, "flow", up.VLESS.Flow, "transport", up.Transport)
|
||||
}
|
||||
|
||||
timeout := d.DialTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = 10 * time.Second
|
||||
}
|
||||
|
||||
var c net.Conn
|
||||
var err error
|
||||
|
||||
switch up.Transport {
|
||||
case "ws":
|
||||
sni := up.VLESS.SNI
|
||||
if sni == "" {
|
||||
sni = up.Host
|
||||
}
|
||||
// Use wss:// only when security is explicitly "tls"; empty/"none" → plain ws://.
|
||||
c, err = transport.DialWS(ctx, "tcp", up.Address(), up.Path, sni, up.VLESS.Security == "tls")
|
||||
|
||||
case "grpc":
|
||||
// gRPC requires the security layer to be established BEFORE HTTP/2.
|
||||
// Dial raw TCP, apply Reality/TLS, then put gRPC on top.
|
||||
c, err = d.dialGRPC(ctx, up, timeout)
|
||||
|
||||
default:
|
||||
dialer := net.Dialer{Timeout: timeout}
|
||||
c, err = dialer.DialContext(ctx, "tcp", up.Address())
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("vless: connect: %w", err)
|
||||
}
|
||||
|
||||
if d.Log != nil {
|
||||
d.Log.Debug("vless connected", "upstream", up.Address())
|
||||
}
|
||||
|
||||
// Apply security for non-gRPC transports (gRPC handles it internally in dialGRPC).
|
||||
if up.Transport != "grpc" {
|
||||
if up.VLESS.Security == "reality" {
|
||||
if d.Log != nil {
|
||||
d.Log.Debug("vless starting reality handshake", "sni", up.VLESS.SNI)
|
||||
}
|
||||
// Use a separate var so we keep the original c reference for Close() on error.
|
||||
tlsConn, realityErr := StreamRealityConn(ctx, c, up.VLESS)
|
||||
if realityErr != nil {
|
||||
c.Close()
|
||||
return nil, fmt.Errorf("vless: reality handshake: %w", realityErr)
|
||||
}
|
||||
c = tlsConn
|
||||
if d.Log != nil {
|
||||
d.Log.Debug("vless reality handshake success")
|
||||
}
|
||||
} else if up.VLESS.Security == "tls" && up.Transport != "ws" {
|
||||
tlsConfig := &tls.Config{
|
||||
ServerName: up.VLESS.SNI,
|
||||
InsecureSkipVerify: true,
|
||||
}
|
||||
tlsConn := tls.Client(c, tlsConfig)
|
||||
if err = tlsConn.HandshakeContext(ctx); err != nil {
|
||||
c.Close()
|
||||
return nil, fmt.Errorf("vless: tls handshake: %w", err)
|
||||
}
|
||||
c = tlsConn
|
||||
}
|
||||
}
|
||||
|
||||
conn, err := NewConn(c, up.VLESS.UUID, up.VLESS.Flow, dest)
|
||||
if err != nil {
|
||||
c.Close()
|
||||
return nil, fmt.Errorf("vless: setup conn: %w", err)
|
||||
}
|
||||
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// dialGRPC handles the gRPC transport: TCP → security → HTTP/2.
|
||||
func (d *Dialer) dialGRPC(ctx context.Context, up *upstream.Upstream, timeout time.Duration) (net.Conn, error) {
|
||||
rawDialer := net.Dialer{Timeout: timeout}
|
||||
raw, err := rawDialer.DialContext(ctx, "tcp", up.Address())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var underlying net.Conn
|
||||
switch up.VLESS.Security {
|
||||
case "reality":
|
||||
if d.Log != nil {
|
||||
d.Log.Debug("vless grpc starting reality handshake", "sni", up.VLESS.SNI)
|
||||
}
|
||||
underlying, err = StreamRealityConn(ctx, raw, up.VLESS)
|
||||
if err != nil {
|
||||
raw.Close()
|
||||
return nil, fmt.Errorf("reality handshake: %w", err)
|
||||
}
|
||||
if d.Log != nil {
|
||||
d.Log.Debug("vless grpc reality handshake success")
|
||||
}
|
||||
case "tls":
|
||||
tlsCfg := &tls.Config{
|
||||
ServerName: up.VLESS.SNI,
|
||||
InsecureSkipVerify: true,
|
||||
NextProtos: []string{"h2"},
|
||||
}
|
||||
tlsConn := tls.Client(raw, tlsCfg)
|
||||
if err = tlsConn.HandshakeContext(ctx); err != nil {
|
||||
raw.Close()
|
||||
return nil, fmt.Errorf("tls handshake: %w", err)
|
||||
}
|
||||
underlying = tlsConn
|
||||
default:
|
||||
underlying = raw
|
||||
}
|
||||
|
||||
conn, err := transport.DialGRPCOverConn(ctx, underlying, up.Address(), up.ServiceName)
|
||||
if err != nil {
|
||||
underlying.Close()
|
||||
return nil, fmt.Errorf("grpc: %w", err)
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package vless
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/ecdh"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
utls "github.com/refraction-networking/utls"
|
||||
"golang.org/x/crypto/hkdf"
|
||||
|
||||
"git.qomar.pw/omar/zhguchiy_perchik/internal/upstream"
|
||||
)
|
||||
|
||||
var fingerprintMap = map[string]*utls.ClientHelloID{
|
||||
"chrome": &utls.HelloChrome_Auto,
|
||||
"firefox": &utls.HelloFirefox_Auto,
|
||||
"safari": &utls.HelloSafari_Auto,
|
||||
"ios": &utls.HelloIOS_Auto,
|
||||
"android": &utls.HelloAndroid_11_OkHttp,
|
||||
"edge": &utls.HelloEdge_Auto,
|
||||
"360": &utls.Hello360_Auto,
|
||||
"qq": &utls.HelloQQ_Auto,
|
||||
"random": &utls.HelloRandomized,
|
||||
"randomized": &utls.HelloRandomized,
|
||||
}
|
||||
|
||||
func StreamRealityConn(ctx context.Context, conn net.Conn, cfg *upstream.VLESSConfig) (net.Conn, error) {
|
||||
if cfg.PublicKey == "" {
|
||||
return nil, errors.New("vless: Reality public key is required")
|
||||
}
|
||||
|
||||
publicKeyBytes, err := decodePublicKey(cfg.PublicKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
serverPubKey, err := ecdh.X25519().NewPublicKey(publicKeyBytes)
|
||||
if err != nil {
|
||||
return nil, errors.New("vless: invalid Reality public key format")
|
||||
}
|
||||
|
||||
var shortID [8]byte
|
||||
if cfg.ShortID != "" {
|
||||
sid, err := hex.DecodeString(cfg.ShortID)
|
||||
if err != nil {
|
||||
return nil, errors.New("vless: invalid Reality short ID")
|
||||
}
|
||||
copy(shortID[:], sid)
|
||||
}
|
||||
|
||||
// Prefer a very standard fingerprint if not specified
|
||||
fingerprint := &utls.HelloChrome_Auto
|
||||
if cfg.Fingerprint != "" {
|
||||
if fp, ok := fingerprintMap[cfg.Fingerprint]; ok {
|
||||
fingerprint = fp
|
||||
}
|
||||
}
|
||||
|
||||
utlsConfig := &utls.Config{
|
||||
ServerName: cfg.SNI,
|
||||
InsecureSkipVerify: true,
|
||||
SessionTicketsDisabled: true,
|
||||
}
|
||||
|
||||
uConn := utls.UClient(conn, utlsConfig, *fingerprint)
|
||||
|
||||
if err := uConn.BuildHandshakeState(); err != nil {
|
||||
return nil, fmt.Errorf("build handshake state: %w", err)
|
||||
}
|
||||
|
||||
hello := uConn.HandshakeState.Hello
|
||||
|
||||
// Resolve the X25519 ECDHE private key: Chrome 133+ uses X25519MLKEM768 hybrid,
|
||||
// where the X25519 component is in KeyShareKeys.MlkemEcdhe, not EcdheKey.
|
||||
var ecdheKey *ecdh.PrivateKey
|
||||
if uConn.HandshakeState.State13.EcdheKey != nil {
|
||||
ecdheKey = uConn.HandshakeState.State13.EcdheKey
|
||||
} else if ksk := uConn.HandshakeState.State13.KeyShareKeys; ksk != nil {
|
||||
if ksk.Ecdhe != nil {
|
||||
ecdheKey = ksk.Ecdhe
|
||||
} else if ksk.MlkemEcdhe != nil {
|
||||
ecdheKey = ksk.MlkemEcdhe // X25519 component of X25519MLKEM768 hybrid
|
||||
}
|
||||
}
|
||||
if ecdheKey == nil {
|
||||
return nil, errors.New("uTLS failed to initialize TLS 1.3 ECDHE state (try chrome/firefox fingerprint)")
|
||||
}
|
||||
|
||||
// Step 1: zero out the session_id slot in hello.Raw (offset 39, 32 bytes).
|
||||
// This zeroed hello.Raw is used as AAD for AES-GCM so the server can reconstruct
|
||||
// it and verify our auth signal. Must happen BEFORE filling hello.SessionId.
|
||||
hello.SessionId = make([]byte, 32)
|
||||
copy(hello.Raw[39:], hello.SessionId) // hello.Raw[39:71] = 00 00 ... 00
|
||||
|
||||
// Step 2: fill hello.SessionId with the plaintext Reality auth signal.
|
||||
// Layout: [version(1) | reserved(3) | timestamp(4) | shortID(8) | zeros(16)]
|
||||
hello.SessionId[0] = 1
|
||||
hello.SessionId[1] = 8
|
||||
hello.SessionId[2] = 24
|
||||
hello.SessionId[3] = 0
|
||||
binary.BigEndian.PutUint32(hello.SessionId[4:8], uint32(time.Now().Unix()))
|
||||
copy(hello.SessionId[8:16], shortID[:])
|
||||
|
||||
// Step 3: ECDH shared secret + HKDF-derived key.
|
||||
authKey, err := ecdheKey.ECDH(serverPubKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ecdh: %w", err)
|
||||
}
|
||||
|
||||
derivedKey := make([]byte, 32)
|
||||
if _, err := hkdf.New(sha256.New, authKey, hello.Random[:20], []byte("REALITY")).Read(derivedKey); err != nil {
|
||||
return nil, fmt.Errorf("hkdf: %w", err)
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(derivedKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
aead, _ := cipher.NewGCM(block)
|
||||
|
||||
// Step 4: seal hello.SessionId[:16] into hello.SessionId[:32].
|
||||
// Nonce = hello.Random[20:32] (12 bytes).
|
||||
// AAD = hello.Raw with zeros at the session_id slot (from Step 1).
|
||||
// The server mirrors this: it zeros hello.Raw[39:71] before calling Open().
|
||||
aead.Seal(hello.SessionId[:0], hello.Random[20:], hello.SessionId[:16], hello.Raw)
|
||||
|
||||
// Step 5: write the sealed SessionId back into hello.Raw, then marshal once.
|
||||
copy(hello.Raw[39:], hello.SessionId)
|
||||
if err := uConn.MarshalClientHello(); err != nil {
|
||||
return nil, fmt.Errorf("marshal hello: %w", err)
|
||||
}
|
||||
|
||||
// Step 6: perform TLS handshake — the server now sees our auth signal in SessionId.
|
||||
if err := uConn.HandshakeContext(ctx); err != nil {
|
||||
return nil, fmt.Errorf("tls handshake: %w", err)
|
||||
}
|
||||
|
||||
return uConn, nil
|
||||
}
|
||||
|
||||
func decodePublicKey(key string) ([]byte, error) {
|
||||
if decoded, err := base64.RawURLEncoding.DecodeString(key); err == nil && len(decoded) == 32 {
|
||||
return decoded, nil
|
||||
}
|
||||
if decoded, err := base64.StdEncoding.DecodeString(key); err == nil && len(decoded) == 32 {
|
||||
return decoded, nil
|
||||
}
|
||||
if decoded, err := base64.RawStdEncoding.DecodeString(key); err == nil && len(decoded) == 32 {
|
||||
return decoded, nil
|
||||
}
|
||||
if decoded, err := hex.DecodeString(key); err == nil && len(decoded) == 32 {
|
||||
return decoded, nil
|
||||
}
|
||||
return nil, errors.New("public key must be 32 bytes in base64 or hex format")
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package vless
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
VisionPaddingHeaderLen = 21
|
||||
|
||||
CommandPaddingContinue = 0x00
|
||||
CommandPaddingEnd = 0x01
|
||||
CommandPaddingDirect = 0x02
|
||||
)
|
||||
|
||||
func ApplyVisionPaddingWithCmd(data []byte, uuid []byte, cmd byte) []byte {
|
||||
paddingLen := 0
|
||||
if len(data) < 900 {
|
||||
paddingLen = 900 - len(data) + randInt(500)
|
||||
} else {
|
||||
paddingLen = randInt(256)
|
||||
}
|
||||
|
||||
frameLen := VisionPaddingHeaderLen + len(data) + paddingLen
|
||||
frame := make([]byte, frameLen)
|
||||
offset := 0
|
||||
|
||||
if uuid != nil && len(uuid) >= 16 {
|
||||
copy(frame[offset:], uuid[:16])
|
||||
}
|
||||
offset += 16
|
||||
|
||||
frame[offset] = cmd
|
||||
offset++
|
||||
|
||||
binary.BigEndian.PutUint16(frame[offset:], uint16(len(data)))
|
||||
offset += 2
|
||||
|
||||
binary.BigEndian.PutUint16(frame[offset:], uint16(paddingLen))
|
||||
offset += 2
|
||||
|
||||
copy(frame[offset:], data)
|
||||
offset += len(data)
|
||||
|
||||
if paddingLen > 0 {
|
||||
rand.Read(frame[offset:])
|
||||
}
|
||||
|
||||
return frame
|
||||
}
|
||||
|
||||
var ErrNotVisionFrame = errors.New("not a Vision frame")
|
||||
var ErrVisionTimeout = errors.New("vision read timeout")
|
||||
|
||||
func ReadVisionPaddedWithTimeout(conn net.Conn, timeout time.Duration) ([]byte, byte, error) {
|
||||
if timeout > 0 {
|
||||
if tc, ok := conn.(interface{ SetReadDeadline(time.Time) error }); ok {
|
||||
tc.SetReadDeadline(time.Now().Add(timeout))
|
||||
defer tc.SetReadDeadline(time.Time{})
|
||||
}
|
||||
}
|
||||
|
||||
firstByte := make([]byte, 1)
|
||||
_, err := conn.Read(firstByte)
|
||||
if err != nil {
|
||||
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
|
||||
return nil, 0, ErrVisionTimeout
|
||||
}
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
if firstByte[0] >= 0x14 && firstByte[0] <= 0x17 {
|
||||
tlsRest := make([]byte, 4)
|
||||
_, err := io.ReadFull(conn, tlsRest)
|
||||
if err != nil {
|
||||
return firstByte, 0, ErrNotVisionFrame
|
||||
}
|
||||
tlsHeader := append(firstByte, tlsRest...)
|
||||
return tlsHeader, 0, ErrNotVisionFrame
|
||||
}
|
||||
|
||||
header := make([]byte, VisionPaddingHeaderLen)
|
||||
header[0] = firstByte[0]
|
||||
_, err = io.ReadFull(conn, header[1:])
|
||||
if err != nil {
|
||||
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
|
||||
return firstByte, 0, ErrNotVisionFrame
|
||||
}
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
cmd := header[16]
|
||||
contentLen := binary.BigEndian.Uint16(header[17:19])
|
||||
paddingLen := binary.BigEndian.Uint16(header[19:21])
|
||||
|
||||
if cmd > CommandPaddingDirect {
|
||||
return header, cmd, ErrNotVisionFrame
|
||||
}
|
||||
if contentLen > 32768 || paddingLen > 4096 {
|
||||
return header, cmd, ErrNotVisionFrame
|
||||
}
|
||||
|
||||
totalLen := int(contentLen) + int(paddingLen)
|
||||
if totalLen == 0 {
|
||||
return []byte{}, cmd, nil
|
||||
}
|
||||
|
||||
data := make([]byte, totalLen)
|
||||
_, err = io.ReadFull(conn, data)
|
||||
if err != nil {
|
||||
return nil, cmd, err
|
||||
}
|
||||
|
||||
return data[:contentLen], cmd, nil
|
||||
}
|
||||
|
||||
func randInt(max int) int {
|
||||
if max <= 0 {
|
||||
return 0
|
||||
}
|
||||
b := make([]byte, 2)
|
||||
rand.Read(b)
|
||||
return int(binary.BigEndian.Uint16(b)) % max
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
package vmess
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/md5"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"hash/fnv"
|
||||
"io"
|
||||
"math/rand"
|
||||
"net"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/chacha20poly1305"
|
||||
)
|
||||
|
||||
type DstAddr struct {
|
||||
UDP bool
|
||||
AddrType byte
|
||||
Addr []byte
|
||||
Port uint
|
||||
}
|
||||
|
||||
type Conn struct {
|
||||
net.Conn
|
||||
reader io.Reader
|
||||
writer io.Writer
|
||||
dst *DstAddr
|
||||
id *ID
|
||||
reqBodyIV []byte
|
||||
reqBodyKey []byte
|
||||
respBodyIV []byte
|
||||
respBodyKey []byte
|
||||
respV byte
|
||||
security byte
|
||||
isAead bool
|
||||
received bool
|
||||
}
|
||||
|
||||
func (vc *Conn) Write(b []byte) (int, error) {
|
||||
return vc.writer.Write(b)
|
||||
}
|
||||
|
||||
func (vc *Conn) Read(b []byte) (int, error) {
|
||||
if vc.received {
|
||||
return vc.reader.Read(b)
|
||||
}
|
||||
if err := vc.recvResponse(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
vc.received = true
|
||||
return vc.reader.Read(b)
|
||||
}
|
||||
|
||||
func (vc *Conn) sendRequest() error {
|
||||
timestamp := time.Now()
|
||||
buf := &bytes.Buffer{}
|
||||
|
||||
buf.WriteByte(Version)
|
||||
buf.Write(vc.reqBodyIV[:])
|
||||
buf.Write(vc.reqBodyKey[:])
|
||||
buf.WriteByte(vc.respV)
|
||||
buf.WriteByte(OptionChunkStream)
|
||||
|
||||
p := rand.Intn(16)
|
||||
buf.WriteByte(byte(p<<4) | byte(vc.security))
|
||||
buf.WriteByte(0)
|
||||
if vc.dst.UDP {
|
||||
buf.WriteByte(CommandUDP)
|
||||
} else {
|
||||
buf.WriteByte(CommandTCP)
|
||||
}
|
||||
|
||||
binary.Write(buf, binary.BigEndian, uint16(vc.dst.Port))
|
||||
buf.WriteByte(vc.dst.AddrType)
|
||||
buf.Write(vc.dst.Addr)
|
||||
|
||||
if p > 0 {
|
||||
padding := make([]byte, p)
|
||||
rand.Read(padding)
|
||||
buf.Write(padding)
|
||||
}
|
||||
|
||||
fnv1a := fnv.New32a()
|
||||
fnv1a.Write(buf.Bytes())
|
||||
buf.Write(fnv1a.Sum(nil))
|
||||
|
||||
var fixedLengthCmdKey [16]byte
|
||||
copy(fixedLengthCmdKey[:], vc.id.CmdKey)
|
||||
vmessout := sealVMessAEADHeader(fixedLengthCmdKey, buf.Bytes(), timestamp)
|
||||
_, err := vc.Conn.Write(vmessout)
|
||||
return err
|
||||
}
|
||||
|
||||
func (vc *Conn) recvResponse() error {
|
||||
aeadResponseHeaderLengthEncryptionKey := kdf(vc.respBodyKey[:], kdfSaltConstAEADRespHeaderLenKey)[:16]
|
||||
aeadResponseHeaderLengthEncryptionIV := kdf(vc.respBodyIV[:], kdfSaltConstAEADRespHeaderLenIV)[:12]
|
||||
|
||||
aeadResponseHeaderLengthEncryptionKeyAESBlock, _ := aes.NewCipher(aeadResponseHeaderLengthEncryptionKey)
|
||||
aeadResponseHeaderLengthEncryptionAEAD, _ := cipher.NewGCM(aeadResponseHeaderLengthEncryptionKeyAESBlock)
|
||||
|
||||
aeadEncryptedResponseHeaderLength := make([]byte, 18)
|
||||
if _, err := io.ReadFull(vc.Conn, aeadEncryptedResponseHeaderLength); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
decryptedResponseHeaderLengthBinaryBuffer, err := aeadResponseHeaderLengthEncryptionAEAD.Open(nil, aeadResponseHeaderLengthEncryptionIV, aeadEncryptedResponseHeaderLength[:], nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
decryptedResponseHeaderLength := binary.BigEndian.Uint16(decryptedResponseHeaderLengthBinaryBuffer)
|
||||
aeadResponseHeaderPayloadEncryptionKey := kdf(vc.respBodyKey[:], kdfSaltConstAEADRespHeaderPayloadKey)[:16]
|
||||
aeadResponseHeaderPayloadEncryptionIV := kdf(vc.respBodyIV[:], kdfSaltConstAEADRespHeaderPayloadIV)[:12]
|
||||
aeadResponseHeaderPayloadEncryptionKeyAESBlock, _ := aes.NewCipher(aeadResponseHeaderPayloadEncryptionKey)
|
||||
aeadResponseHeaderPayloadEncryptionAEAD, _ := cipher.NewGCM(aeadResponseHeaderPayloadEncryptionKeyAESBlock)
|
||||
|
||||
encryptedResponseHeaderBuffer := make([]byte, decryptedResponseHeaderLength+16)
|
||||
if _, err := io.ReadFull(vc.Conn, encryptedResponseHeaderBuffer); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
buf, err := aeadResponseHeaderPayloadEncryptionAEAD.Open(nil, aeadResponseHeaderPayloadEncryptionIV, encryptedResponseHeaderBuffer, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(buf) < 4 {
|
||||
return errors.New("unexpected buffer length")
|
||||
}
|
||||
|
||||
if buf[0] != vc.respV {
|
||||
return errors.New("unexpected response header")
|
||||
}
|
||||
|
||||
if buf[2] != 0 {
|
||||
return errors.New("dynamic port is not supported")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func newConn(conn net.Conn, id *ID, dst *DstAddr, security Security) (*Conn, error) {
|
||||
randBytes := make([]byte, 33)
|
||||
rand.Read(randBytes)
|
||||
reqBodyIV := make([]byte, 16)
|
||||
reqBodyKey := make([]byte, 16)
|
||||
copy(reqBodyIV[:], randBytes[:16])
|
||||
copy(reqBodyKey[:], randBytes[16:32])
|
||||
respV := randBytes[32]
|
||||
|
||||
bodyKey := sha256.Sum256(reqBodyKey)
|
||||
bodyIV := sha256.Sum256(reqBodyIV)
|
||||
respBodyKey := bodyKey[:16]
|
||||
respBodyIV := bodyIV[:16]
|
||||
|
||||
var writer io.Writer
|
||||
var reader io.Reader
|
||||
switch security {
|
||||
case SecurityNone:
|
||||
reader = newChunkReader(conn)
|
||||
writer = newChunkWriter(conn)
|
||||
case SecurityAES128GCM:
|
||||
block, _ := aes.NewCipher(reqBodyKey[:])
|
||||
aead, _ := cipher.NewGCM(block)
|
||||
writer = newAEADWriter(conn, aead, reqBodyIV[:])
|
||||
|
||||
block, _ = aes.NewCipher(respBodyKey[:])
|
||||
aead, _ = cipher.NewGCM(block)
|
||||
reader = newAEADReader(conn, aead, respBodyIV[:])
|
||||
case SecurityCHACHA20POLY1305:
|
||||
key := make([]byte, 32)
|
||||
t := md5.Sum(reqBodyKey[:])
|
||||
copy(key, t[:])
|
||||
t = md5.Sum(key[:16])
|
||||
copy(key[16:], t[:])
|
||||
aead, _ := chacha20poly1305.New(key)
|
||||
writer = newAEADWriter(conn, aead, reqBodyIV[:])
|
||||
|
||||
t = md5.Sum(respBodyKey[:])
|
||||
copy(key, t[:])
|
||||
t = md5.Sum(key[:16])
|
||||
copy(key[16:], t[:])
|
||||
aead, _ = chacha20poly1305.New(key)
|
||||
reader = newAEADReader(conn, aead, respBodyIV[:])
|
||||
}
|
||||
|
||||
c := &Conn{
|
||||
Conn: conn,
|
||||
id: id,
|
||||
dst: dst,
|
||||
reqBodyIV: reqBodyIV,
|
||||
reqBodyKey: reqBodyKey,
|
||||
respV: respV,
|
||||
respBodyIV: respBodyIV[:],
|
||||
respBodyKey: respBodyKey[:],
|
||||
reader: reader,
|
||||
writer: writer,
|
||||
security: security,
|
||||
isAead: true,
|
||||
}
|
||||
if err := c.sendRequest(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func ParseDest(dest string) (*DstAddr, error) {
|
||||
host, portStr, err := net.SplitHostPort(dest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
port, _ := strconv.Atoi(portStr)
|
||||
|
||||
var addrType byte
|
||||
var addr []byte
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
if v4 := ip.To4(); v4 != nil {
|
||||
addrType = AtypIPv4
|
||||
addr = make([]byte, net.IPv4len)
|
||||
copy(addr, v4)
|
||||
} else {
|
||||
addrType = AtypIPv6
|
||||
addr = make([]byte, net.IPv6len)
|
||||
copy(addr, ip.To16())
|
||||
}
|
||||
} else {
|
||||
addrType = AtypDomainName
|
||||
addr = make([]byte, len(host)+1)
|
||||
addr[0] = byte(len(host))
|
||||
copy(addr[1:], []byte(host))
|
||||
}
|
||||
|
||||
return &DstAddr{
|
||||
UDP: false,
|
||||
AddrType: addrType,
|
||||
Addr: addr,
|
||||
Port: uint(port),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// AEAD reader/writer
|
||||
|
||||
type aeadWriter struct {
|
||||
io.Writer
|
||||
cipher.AEAD
|
||||
nonce [32]byte
|
||||
count uint16
|
||||
iv []byte
|
||||
writeLock sync.Mutex
|
||||
}
|
||||
|
||||
func newAEADWriter(w io.Writer, aead cipher.AEAD, iv []byte) *aeadWriter {
|
||||
return &aeadWriter{Writer: w, AEAD: aead, iv: iv}
|
||||
}
|
||||
|
||||
func (w *aeadWriter) Write(b []byte) (n int, err error) {
|
||||
w.writeLock.Lock()
|
||||
defer w.writeLock.Unlock()
|
||||
|
||||
buf := make([]byte, maxSize+lenSize)
|
||||
length := len(b)
|
||||
for {
|
||||
if length == 0 {
|
||||
break
|
||||
}
|
||||
readLen := chunkSize - w.Overhead()
|
||||
if length < readLen {
|
||||
readLen = length
|
||||
}
|
||||
payloadBuf := buf[lenSize : lenSize+chunkSize-w.Overhead()]
|
||||
copy(payloadBuf, b[n:n+readLen])
|
||||
|
||||
binary.BigEndian.PutUint16(buf[:lenSize], uint16(readLen+w.Overhead()))
|
||||
binary.BigEndian.PutUint16(w.nonce[:2], w.count)
|
||||
copy(w.nonce[2:], w.iv[2:12])
|
||||
|
||||
w.Seal(payloadBuf[:0], w.nonce[:w.NonceSize()], payloadBuf[:readLen], nil)
|
||||
w.count++
|
||||
|
||||
_, err = w.Writer.Write(buf[:lenSize+readLen+w.Overhead()])
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
n += readLen
|
||||
length -= readLen
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
type aeadReader struct {
|
||||
io.Reader
|
||||
cipher.AEAD
|
||||
nonce [32]byte
|
||||
buf []byte
|
||||
offset int
|
||||
iv []byte
|
||||
sizeBuf []byte
|
||||
count uint16
|
||||
}
|
||||
|
||||
func newAEADReader(r io.Reader, aead cipher.AEAD, iv []byte) *aeadReader {
|
||||
return &aeadReader{Reader: r, AEAD: aead, iv: iv, sizeBuf: make([]byte, lenSize)}
|
||||
}
|
||||
|
||||
func (r *aeadReader) Read(b []byte) (int, error) {
|
||||
if r.buf != nil {
|
||||
n := copy(b, r.buf[r.offset:])
|
||||
r.offset += n
|
||||
if r.offset == len(r.buf) {
|
||||
r.buf = nil
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
_, err := io.ReadFull(r.Reader, r.sizeBuf)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
size := int(binary.BigEndian.Uint16(r.sizeBuf))
|
||||
if size > maxSize {
|
||||
return 0, errors.New("buffer is larger than standard")
|
||||
}
|
||||
|
||||
buf := make([]byte, size)
|
||||
_, err = io.ReadFull(r.Reader, buf)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
binary.BigEndian.PutUint16(r.nonce[:2], r.count)
|
||||
copy(r.nonce[2:], r.iv[2:12])
|
||||
|
||||
_, err = r.Open(buf[:0], r.nonce[:r.NonceSize()], buf[:size], nil)
|
||||
r.count++
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
realLen := size - r.Overhead()
|
||||
n := copy(b, buf[:realLen])
|
||||
if len(b) >= realLen {
|
||||
return n, nil
|
||||
}
|
||||
|
||||
r.offset = n
|
||||
r.buf = buf[:realLen]
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// Chunk reader/writer (for SecurityNone)
|
||||
|
||||
type chunkReader struct {
|
||||
io.Reader
|
||||
buf []byte
|
||||
sizeBuf []byte
|
||||
offset int
|
||||
}
|
||||
|
||||
func newChunkReader(reader io.Reader) *chunkReader {
|
||||
return &chunkReader{Reader: reader, sizeBuf: make([]byte, lenSize)}
|
||||
}
|
||||
|
||||
type chunkWriter struct {
|
||||
io.Writer
|
||||
}
|
||||
|
||||
func newChunkWriter(writer io.Writer) *chunkWriter {
|
||||
return &chunkWriter{Writer: writer}
|
||||
}
|
||||
|
||||
func (cr *chunkReader) Read(b []byte) (int, error) {
|
||||
if cr.buf != nil {
|
||||
n := copy(b, cr.buf[cr.offset:])
|
||||
cr.offset += n
|
||||
if cr.offset == len(cr.buf) {
|
||||
cr.buf = nil
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
_, err := io.ReadFull(cr.Reader, cr.sizeBuf)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
size := int(binary.BigEndian.Uint16(cr.sizeBuf))
|
||||
if size > maxSize {
|
||||
return 0, errors.New("buffer is larger than standard")
|
||||
}
|
||||
|
||||
if len(b) >= size {
|
||||
_, err := io.ReadFull(cr.Reader, b[:size])
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return size, nil
|
||||
}
|
||||
|
||||
buf := make([]byte, size)
|
||||
_, err = io.ReadFull(cr.Reader, buf)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n := copy(b, buf)
|
||||
cr.offset = n
|
||||
cr.buf = buf
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (cw *chunkWriter) Write(b []byte) (n int, err error) {
|
||||
buf := make([]byte, maxSize+lenSize)
|
||||
length := len(b)
|
||||
for {
|
||||
if length == 0 {
|
||||
break
|
||||
}
|
||||
readLen := chunkSize
|
||||
if length < chunkSize {
|
||||
readLen = length
|
||||
}
|
||||
payloadBuf := buf[lenSize : lenSize+chunkSize]
|
||||
copy(payloadBuf, b[n:n+readLen])
|
||||
|
||||
binary.BigEndian.PutUint16(buf[:lenSize], uint16(readLen))
|
||||
_, err = cw.Writer.Write(buf[:lenSize+readLen])
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
n += readLen
|
||||
length -= readLen
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package vmess
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"git.qomar.pw/omar/zhguchiy_perchik/internal/dialer/transport"
|
||||
"git.qomar.pw/omar/zhguchiy_perchik/internal/upstream"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
)
|
||||
|
||||
type Dialer struct {
|
||||
DialTimeout time.Duration
|
||||
Log *slog.Logger
|
||||
}
|
||||
|
||||
func (d *Dialer) Dial(ctx context.Context, up *upstream.Upstream, dest string) (net.Conn, error) {
|
||||
if up.VMess == nil {
|
||||
return nil, fmt.Errorf("vmess: missing config")
|
||||
}
|
||||
|
||||
if d.Log != nil {
|
||||
d.Log.Debug("vmess dial", "upstream", up.Address(), "dest", dest, "security", up.VMess.Security, "transport", up.Transport)
|
||||
}
|
||||
|
||||
timeout := d.DialTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = 10 * time.Second
|
||||
}
|
||||
|
||||
var c net.Conn
|
||||
var err error
|
||||
|
||||
switch up.Transport {
|
||||
case "ws":
|
||||
sni := up.VMess.ServerName
|
||||
if sni == "" {
|
||||
sni = up.Host
|
||||
}
|
||||
c, err = transport.DialWS(ctx, "tcp", up.Address(), up.Path, sni, up.VMess.TLS)
|
||||
case "grpc":
|
||||
tlsConfig := &tls.Config{
|
||||
ServerName: up.VMess.ServerName,
|
||||
InsecureSkipVerify: true,
|
||||
}
|
||||
c, err = transport.DialGRPC(ctx, up.Address(), up.ServiceName, tlsConfig)
|
||||
default:
|
||||
dialer := net.Dialer{Timeout: timeout}
|
||||
c, err = dialer.DialContext(ctx, "tcp", up.Address())
|
||||
if err == nil && up.VMess.TLS {
|
||||
sni := up.VMess.ServerName
|
||||
if sni == "" {
|
||||
sni = up.Host
|
||||
}
|
||||
tlsConfig := &tls.Config{
|
||||
ServerName: sni,
|
||||
InsecureSkipVerify: true,
|
||||
}
|
||||
tlsConn := tls.Client(c, tlsConfig)
|
||||
if err = tlsConn.HandshakeContext(ctx); err != nil {
|
||||
c.Close()
|
||||
return nil, fmt.Errorf("vmess: tls handshake: %w", err)
|
||||
}
|
||||
c = tlsConn
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("vmess: connect: %w", err)
|
||||
}
|
||||
|
||||
uid, err := uuid.FromString(up.VMess.UUID)
|
||||
if err != nil {
|
||||
c.Close()
|
||||
return nil, fmt.Errorf("vmess: invalid uuid: %w", err)
|
||||
}
|
||||
|
||||
security := resolveSecurity(up.VMess.Security)
|
||||
id := newID(&uid)
|
||||
|
||||
dstAddr, err := ParseDest(dest)
|
||||
if err != nil {
|
||||
c.Close()
|
||||
return nil, fmt.Errorf("vmess: parse dest: %w", err)
|
||||
}
|
||||
|
||||
conn, err := newConn(c, id, dstAddr, security)
|
||||
if err != nil {
|
||||
c.Close()
|
||||
return nil, fmt.Errorf("vmess: setup conn: %w", err)
|
||||
}
|
||||
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
func resolveSecurity(s string) Security {
|
||||
if sec, ok := CipherMapping[s]; ok {
|
||||
return sec
|
||||
}
|
||||
if runtime.GOARCH == "amd64" || runtime.GOARCH == "s390x" || runtime.GOARCH == "arm64" {
|
||||
return SecurityAES128GCM
|
||||
}
|
||||
return SecurityCHACHA20POLY1305
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package vmess
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"hash"
|
||||
"hash/crc32"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
kdfSaltConstAuthIDEncryptionKey = "AES Auth ID Encryption"
|
||||
kdfSaltConstAEADRespHeaderLenKey = "AEAD Resp Header Len Key"
|
||||
kdfSaltConstAEADRespHeaderLenIV = "AEAD Resp Header Len IV"
|
||||
kdfSaltConstAEADRespHeaderPayloadKey = "AEAD Resp Header Key"
|
||||
kdfSaltConstAEADRespHeaderPayloadIV = "AEAD Resp Header IV"
|
||||
kdfSaltConstVMessAEADKDF = "VMess AEAD KDF"
|
||||
kdfSaltConstVMessHeaderPayloadAEADKey = "VMess Header AEAD Key"
|
||||
kdfSaltConstVMessHeaderPayloadAEADIV = "VMess Header AEAD Nonce"
|
||||
kdfSaltConstVMessHeaderPayloadLengthAEADKey = "VMess Header AEAD Key_Length"
|
||||
kdfSaltConstVMessHeaderPayloadLengthAEADIV = "VMess Header AEAD Nonce_Length"
|
||||
)
|
||||
|
||||
func kdf(key []byte, path ...string) []byte {
|
||||
hmacCreator := &hMacCreator{value: []byte(kdfSaltConstVMessAEADKDF)}
|
||||
for _, v := range path {
|
||||
hmacCreator = &hMacCreator{value: []byte(v), parent: hmacCreator}
|
||||
}
|
||||
hmacf := hmacCreator.Create()
|
||||
hmacf.Write(key)
|
||||
return hmacf.Sum(nil)
|
||||
}
|
||||
|
||||
type hMacCreator struct {
|
||||
parent *hMacCreator
|
||||
value []byte
|
||||
}
|
||||
|
||||
func (h *hMacCreator) Create() hash.Hash {
|
||||
if h.parent == nil {
|
||||
return hmac.New(sha256.New, h.value)
|
||||
}
|
||||
return hmac.New(h.parent.Create, h.value)
|
||||
}
|
||||
|
||||
func createAuthID(cmdKey []byte, t int64) [16]byte {
|
||||
buf := &bytes.Buffer{}
|
||||
binary.Write(buf, binary.BigEndian, t) // 8 bytes
|
||||
|
||||
random := make([]byte, 4)
|
||||
rand.Read(random)
|
||||
buf.Write(random) // 4 bytes → buf = 12 bytes
|
||||
|
||||
zero := crc32.ChecksumIEEE(buf.Bytes()) // CRC32 of 12-byte prefix
|
||||
binary.Write(buf, binary.BigEndian, zero) // 4 bytes → buf = 16 bytes
|
||||
|
||||
aesBlock, _ := aes.NewCipher(kdf(cmdKey[:], kdfSaltConstAuthIDEncryptionKey)[:16])
|
||||
var result [16]byte
|
||||
aesBlock.Encrypt(result[:], buf.Bytes())
|
||||
return result
|
||||
}
|
||||
|
||||
func sealVMessAEADHeader(key [16]byte, data []byte, t time.Time) []byte {
|
||||
generatedAuthID := createAuthID(key[:], t.Unix())
|
||||
connectionNonce := make([]byte, 8)
|
||||
rand.Read(connectionNonce)
|
||||
|
||||
aeadPayloadLengthSerializedByte := make([]byte, 2)
|
||||
binary.BigEndian.PutUint16(aeadPayloadLengthSerializedByte, uint16(len(data)))
|
||||
|
||||
var payloadHeaderLengthAEADEncrypted []byte
|
||||
{
|
||||
payloadHeaderLengthAEADKey := kdf(key[:], kdfSaltConstVMessHeaderPayloadLengthAEADKey, string(generatedAuthID[:]), string(connectionNonce))[:16]
|
||||
payloadHeaderLengthAEADNonce := kdf(key[:], kdfSaltConstVMessHeaderPayloadLengthAEADIV, string(generatedAuthID[:]), string(connectionNonce))[:12]
|
||||
payloadHeaderLengthAEADAESBlock, _ := aes.NewCipher(payloadHeaderLengthAEADKey)
|
||||
payloadHeaderAEAD, _ := cipher.NewGCM(payloadHeaderLengthAEADAESBlock)
|
||||
payloadHeaderLengthAEADEncrypted = payloadHeaderAEAD.Seal(nil, payloadHeaderLengthAEADNonce, aeadPayloadLengthSerializedByte, generatedAuthID[:])
|
||||
}
|
||||
|
||||
var payloadHeaderAEADEncrypted []byte
|
||||
{
|
||||
payloadHeaderAEADKey := kdf(key[:], kdfSaltConstVMessHeaderPayloadAEADKey, string(generatedAuthID[:]), string(connectionNonce))[:16]
|
||||
payloadHeaderAEADNonce := kdf(key[:], kdfSaltConstVMessHeaderPayloadAEADIV, string(generatedAuthID[:]), string(connectionNonce))[:12]
|
||||
payloadHeaderAEADAESBlock, _ := aes.NewCipher(payloadHeaderAEADKey)
|
||||
payloadHeaderAEAD, _ := cipher.NewGCM(payloadHeaderAEADAESBlock)
|
||||
payloadHeaderAEADEncrypted = payloadHeaderAEAD.Seal(nil, payloadHeaderAEADNonce, data, generatedAuthID[:])
|
||||
}
|
||||
|
||||
var outputBuffer = &bytes.Buffer{}
|
||||
outputBuffer.Write(generatedAuthID[:])
|
||||
outputBuffer.Write(payloadHeaderLengthAEADEncrypted)
|
||||
outputBuffer.Write(connectionNonce)
|
||||
outputBuffer.Write(payloadHeaderAEADEncrypted)
|
||||
|
||||
return outputBuffer.Bytes()
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package vmess
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
)
|
||||
|
||||
const IDBytesLen = 16
|
||||
|
||||
type ID struct {
|
||||
UUID *uuid.UUID
|
||||
CmdKey []byte
|
||||
}
|
||||
|
||||
func newID(uid *uuid.UUID) *ID {
|
||||
id := &ID{UUID: uid, CmdKey: make([]byte, IDBytesLen)}
|
||||
md5hash := md5.New()
|
||||
md5hash.Write(uid.Bytes())
|
||||
md5hash.Write([]byte("c48619fe-8f02-49e0-b9e9-edf763e17e21"))
|
||||
md5hash.Sum(id.CmdKey[:0])
|
||||
return id
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Package vmess implements the VMess protocol.
|
||||
// Ported from github.com/xxf098/LiteSpeedTest/transport/vmess (GPL-3).
|
||||
package vmess
|
||||
|
||||
const Version byte = 1
|
||||
|
||||
const (
|
||||
OptionChunkStream byte = 1
|
||||
OptionChunkMasking byte = 4
|
||||
)
|
||||
|
||||
type Security = byte
|
||||
|
||||
const (
|
||||
SecurityAES128GCM Security = 3
|
||||
SecurityCHACHA20POLY1305 Security = 4
|
||||
SecurityNone Security = 5
|
||||
)
|
||||
|
||||
var CipherMapping = map[string]byte{
|
||||
"none": SecurityNone,
|
||||
"aes-128-gcm": SecurityAES128GCM,
|
||||
"chacha20-poly1305": SecurityCHACHA20POLY1305,
|
||||
"auto": SecurityAES128GCM,
|
||||
"zero": SecurityNone,
|
||||
}
|
||||
|
||||
const (
|
||||
CommandTCP byte = 1
|
||||
CommandUDP byte = 2
|
||||
)
|
||||
|
||||
const (
|
||||
AtypIPv4 byte = 1
|
||||
AtypDomainName byte = 2
|
||||
AtypIPv6 byte = 3
|
||||
)
|
||||
|
||||
const (
|
||||
lenSize = 2
|
||||
chunkSize = 1 << 14
|
||||
maxSize = 17 * 1024
|
||||
)
|
||||
@@ -0,0 +1,135 @@
|
||||
package fetcher
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Source struct {
|
||||
URL string
|
||||
Name string
|
||||
}
|
||||
|
||||
// FetchAll downloads all sources, retrying failed ones with exponential backoff.
|
||||
// A failure of one source does not abort the others — it is logged and skipped.
|
||||
func FetchAll(ctx context.Context, sources []Source, log *slog.Logger) ([]string, error) {
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
seen := make(map[string]struct{})
|
||||
var result []string
|
||||
|
||||
for _, src := range sources {
|
||||
lines, err := fetchOneWithRetry(ctx, client, src.URL, log)
|
||||
if err != nil {
|
||||
if log != nil {
|
||||
log.Warn("fetch source failed after retries", "url", src.URL, "err", err.Error())
|
||||
}
|
||||
continue
|
||||
}
|
||||
for _, line := range lines {
|
||||
if _, ok := seen[line]; !ok {
|
||||
seen[line] = struct{}{}
|
||||
result = append(result, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// fetchOneWithRetry attempts to fetch a single source up to 3 times with backoff.
|
||||
// Backoff: attempt 1 immediate, attempt 2 after 1s, attempt 3 after 2s.
|
||||
func fetchOneWithRetry(ctx context.Context, client *http.Client, rawURL string, log *slog.Logger) ([]string, error) {
|
||||
delays := []time.Duration{0, time.Second, 2 * time.Second}
|
||||
var lastErr error
|
||||
for attempt, delay := range delays {
|
||||
if delay > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(delay):
|
||||
}
|
||||
}
|
||||
lines, err := fetchOne(ctx, client, rawURL)
|
||||
if err == nil {
|
||||
return lines, nil
|
||||
}
|
||||
if log != nil {
|
||||
log.Debug("fetch attempt failed", "url", rawURL, "attempt", attempt+1, "err", err.Error())
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func fetchOne(ctx context.Context, client *http.Client, rawURL string) ([]string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
content := string(body)
|
||||
if decoded, err := tryBase64(content); err == nil {
|
||||
content = decoded
|
||||
}
|
||||
return parseLines(content), nil
|
||||
}
|
||||
|
||||
func tryBase64(s string) (string, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if strings.Contains(s, "://") && strings.Contains(s, "\n") {
|
||||
return "", io.ErrUnexpectedEOF
|
||||
}
|
||||
for _, enc := range []encoding{
|
||||
{base64.StdEncoding},
|
||||
{base64.RawStdEncoding},
|
||||
{base64.URLEncoding},
|
||||
{base64.RawURLEncoding},
|
||||
} {
|
||||
if b, err := enc.DecodeString(s); err == nil {
|
||||
return string(b), nil
|
||||
}
|
||||
}
|
||||
return "", io.ErrUnexpectedEOF
|
||||
}
|
||||
|
||||
type encoding struct{ *base64.Encoding }
|
||||
|
||||
// ParseContent extracts proxy URLs from arbitrary text. Tries base64 decoding
|
||||
// (subscription format) and falls back to line-by-line parsing. Used by the
|
||||
// admin upload endpoint to import a list pasted by a user.
|
||||
func ParseContent(content string) []string {
|
||||
if decoded, err := tryBase64(content); err == nil {
|
||||
content = decoded
|
||||
}
|
||||
return parseLines(content)
|
||||
}
|
||||
|
||||
func parseLines(content string) []string {
|
||||
var lines []string
|
||||
scanner := bufio.NewScanner(strings.NewReader(content))
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "//") {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(line, "://") {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package geoip
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// downloader handles database file downloads with retry logic.
|
||||
type downloader struct {
|
||||
client *http.Client
|
||||
retries int
|
||||
}
|
||||
|
||||
// newDownloader creates a new downloader with the given HTTP client and retry count.
|
||||
func newDownloader(client *http.Client, retries int) *downloader {
|
||||
return &downloader{
|
||||
client: client,
|
||||
retries: retries,
|
||||
}
|
||||
}
|
||||
|
||||
// download fetches the database from the given URL with exponential backoff retry.
|
||||
// Returns the downloaded bytes or an error if all attempts fail.
|
||||
func (d *downloader) download(ctx context.Context, url string) ([]byte, error) {
|
||||
var lastErr error
|
||||
backoff := time.Second // Initial backoff: 1s, then 2s, 4s...
|
||||
|
||||
for attempt := 1; attempt <= d.retries; attempt++ {
|
||||
data, err := d.doRequest(ctx, url)
|
||||
if err == nil {
|
||||
return data, nil
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
|
||||
// Don't wait after the last attempt
|
||||
if attempt < d.retries {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(backoff):
|
||||
backoff *= 2 // Exponential backoff
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("%w: %v", ErrDownloadFailed, lastErr)
|
||||
}
|
||||
|
||||
// doRequest performs a single HTTP GET request.
|
||||
func (d *downloader) doRequest(ctx context.Context, url string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := d.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("execute request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response body: %w", err)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package geoip
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
// ErrPrivateIP is returned when attempting to lookup a private IP address.
|
||||
ErrPrivateIP = errors.New("private IP address")
|
||||
|
||||
// ErrInvalidIP is returned when the provided IP address is invalid.
|
||||
ErrInvalidIP = errors.New("invalid IP address")
|
||||
|
||||
// ErrNotReady is returned when the database is not yet loaded.
|
||||
ErrNotReady = errors.New("database not ready")
|
||||
|
||||
// ErrDownloadFailed is returned when all download attempts fail.
|
||||
ErrDownloadFailed = errors.New("failed to download database")
|
||||
|
||||
// ErrNotFound is returned when the IP address is not found in the database.
|
||||
ErrNotFound = errors.New("IP address not found in database")
|
||||
)
|
||||
@@ -0,0 +1,76 @@
|
||||
package geoip
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultUpdateInterval = 24 * time.Hour
|
||||
defaultRetries = 3
|
||||
defaultTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
// config holds the resolver configuration.
|
||||
type config struct {
|
||||
updateInterval time.Duration
|
||||
logger *slog.Logger
|
||||
httpClient *http.Client
|
||||
retries int
|
||||
}
|
||||
|
||||
// Option is a functional option for configuring the Resolver.
|
||||
type Option func(*config)
|
||||
|
||||
// newDefaultConfig returns a config with default values.
|
||||
func newDefaultConfig() *config {
|
||||
return &config{
|
||||
updateInterval: defaultUpdateInterval,
|
||||
logger: slog.Default(),
|
||||
httpClient: &http.Client{
|
||||
Timeout: defaultTimeout,
|
||||
},
|
||||
retries: defaultRetries,
|
||||
}
|
||||
}
|
||||
|
||||
// WithUpdateInterval sets the database update interval.
|
||||
// Default is 24 hours.
|
||||
func WithUpdateInterval(d time.Duration) Option {
|
||||
return func(c *config) {
|
||||
if d > 0 {
|
||||
c.updateInterval = d
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithLogger sets the logger for the resolver.
|
||||
// Default is slog.Default().
|
||||
func WithLogger(logger *slog.Logger) Option {
|
||||
return func(c *config) {
|
||||
if logger != nil {
|
||||
c.logger = logger
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithHTTPClient sets the HTTP client for downloading the database.
|
||||
// Default is an http.Client with 30 second timeout.
|
||||
func WithHTTPClient(client *http.Client) Option {
|
||||
return func(c *config) {
|
||||
if client != nil {
|
||||
c.httpClient = client
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithRetries sets the number of retry attempts for downloading.
|
||||
// Default is 3.
|
||||
func WithRetries(n int) Option {
|
||||
return func(c *config) {
|
||||
if n > 0 {
|
||||
c.retries = n
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package geoip
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/oschwald/maxminddb-golang"
|
||||
)
|
||||
|
||||
// countryRecord represents the MMDB record structure for country lookup.
|
||||
// Compatible with ip-location-db format (country_code field).
|
||||
type countryRecord struct {
|
||||
CountryCode string `maxminddb:"country_code"`
|
||||
}
|
||||
|
||||
// Resolver provides IP to country resolution using MaxMind database.
|
||||
// Updates are triggered lazily on lookup when the database becomes stale.
|
||||
type Resolver struct {
|
||||
db atomic.Pointer[maxminddb.Reader]
|
||||
config *config
|
||||
url string
|
||||
updater *updater
|
||||
}
|
||||
|
||||
// New creates a new Resolver with the given database URL and options.
|
||||
// It downloads the initial database before returning.
|
||||
// Database updates happen lazily when Lookup detects staleness.
|
||||
// The provided context is used for background updates (graceful shutdown).
|
||||
func New(ctx context.Context, url string, opts ...Option) (*Resolver, error) {
|
||||
cfg := newDefaultConfig()
|
||||
for _, opt := range opts {
|
||||
opt(cfg)
|
||||
}
|
||||
|
||||
r := &Resolver{
|
||||
config: cfg,
|
||||
url: url,
|
||||
}
|
||||
|
||||
dl := newDownloader(cfg.httpClient, cfg.retries)
|
||||
r.updater = newUpdater(ctx, &r.db, dl, url, cfg.updateInterval, cfg.logger)
|
||||
|
||||
// Load initial database (required)
|
||||
if err := r.updater.loadInitial(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// Lookup returns the country code for the given IP address.
|
||||
// Triggers background update if the database is stale.
|
||||
// Returns ErrPrivateIP for private/local addresses.
|
||||
// Returns ErrNotFound if the IP is not in the database.
|
||||
func (r *Resolver) Lookup(ip net.IP) (string, error) {
|
||||
if ip == nil {
|
||||
return "", ErrInvalidIP
|
||||
}
|
||||
|
||||
if isPrivateIP(ip) {
|
||||
return "", ErrPrivateIP
|
||||
}
|
||||
|
||||
// Check if update needed and trigger if so (non-blocking)
|
||||
r.updater.triggerUpdateIfNeeded()
|
||||
|
||||
reader := r.db.Load()
|
||||
if reader == nil {
|
||||
return "", ErrNotReady
|
||||
}
|
||||
|
||||
var record countryRecord
|
||||
err := reader.Lookup(ip, &record)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if record.CountryCode == "" {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
|
||||
return record.CountryCode, nil
|
||||
}
|
||||
|
||||
// LookupString parses the IP string and returns the country code.
|
||||
// Returns ErrInvalidIP if the string is not a valid IP address.
|
||||
func (r *Resolver) LookupString(ipStr string) (string, error) {
|
||||
ip := net.ParseIP(ipStr)
|
||||
if ip == nil {
|
||||
return "", ErrInvalidIP
|
||||
}
|
||||
return r.Lookup(ip)
|
||||
}
|
||||
|
||||
// Close releases resources held by the resolver.
|
||||
func (r *Resolver) Close() error {
|
||||
if reader := r.db.Load(); reader != nil {
|
||||
return reader.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LastUpdated returns the time when the database was last updated.
|
||||
func (r *Resolver) LastUpdated() time.Time {
|
||||
return r.updater.LastUpdated()
|
||||
}
|
||||
|
||||
// IsUpdating returns true if a background update is in progress.
|
||||
func (r *Resolver) IsUpdating() bool {
|
||||
return r.updater.IsUpdating()
|
||||
}
|
||||
|
||||
// isPrivateIP checks if the IP address is private, loopback, or link-local.
|
||||
func isPrivateIP(ip net.IP) bool {
|
||||
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for unspecified addresses (0.0.0.0 or ::)
|
||||
if ip.IsUnspecified() {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package geoip
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/oschwald/maxminddb-golang"
|
||||
)
|
||||
|
||||
// updater handles lazy database updates triggered by lookups.
|
||||
type updater struct {
|
||||
ctx context.Context // context for background updates
|
||||
db *atomic.Pointer[maxminddb.Reader]
|
||||
lastUpdated atomic.Int64 // unix timestamp in nanoseconds
|
||||
updating atomic.Bool // prevents concurrent updates
|
||||
downloader *downloader
|
||||
url string
|
||||
interval time.Duration
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// newUpdater creates a new updater.
|
||||
func newUpdater(
|
||||
ctx context.Context,
|
||||
db *atomic.Pointer[maxminddb.Reader],
|
||||
downloader *downloader,
|
||||
url string,
|
||||
interval time.Duration,
|
||||
logger *slog.Logger,
|
||||
) *updater {
|
||||
return &updater{
|
||||
ctx: ctx,
|
||||
db: db,
|
||||
downloader: downloader,
|
||||
url: url,
|
||||
interval: interval,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// needsUpdate checks if the database is stale and needs updating.
|
||||
func (u *updater) needsUpdate() bool {
|
||||
lastUpdated := u.lastUpdated.Load()
|
||||
if lastUpdated == 0 {
|
||||
return false // not initialized yet
|
||||
}
|
||||
elapsed := time.Since(time.Unix(0, lastUpdated))
|
||||
return elapsed >= u.interval
|
||||
}
|
||||
|
||||
// triggerUpdateIfNeeded checks if update is needed and starts background update.
|
||||
// Returns immediately, update happens asynchronously.
|
||||
// Uses the context passed during Resolver creation for cancellation.
|
||||
func (u *updater) triggerUpdateIfNeeded() {
|
||||
if !u.needsUpdate() {
|
||||
return
|
||||
}
|
||||
|
||||
// Try to acquire update lock (CAS: false -> true)
|
||||
if !u.updating.CompareAndSwap(false, true) {
|
||||
// Another goroutine is already updating
|
||||
return
|
||||
}
|
||||
|
||||
u.logger.Debug("triggering background database update")
|
||||
|
||||
go func() {
|
||||
defer u.updating.Store(false)
|
||||
u.update(u.ctx)
|
||||
}()
|
||||
}
|
||||
|
||||
// update downloads and loads a new database.
|
||||
func (u *updater) update(ctx context.Context) {
|
||||
data, err := u.downloader.download(ctx, u.url)
|
||||
if err != nil {
|
||||
u.logger.Error("failed to download database",
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
reader, err := maxminddb.FromBytes(data)
|
||||
if err != nil {
|
||||
u.logger.Error("failed to parse database",
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Close the old reader after replacing
|
||||
if old := u.db.Swap(reader); old != nil {
|
||||
if err := old.Close(); err != nil {
|
||||
u.logger.Warn("failed to close old database",
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
u.lastUpdated.Store(time.Now().UnixNano())
|
||||
|
||||
u.logger.Info("database updated successfully",
|
||||
slog.Int("size_bytes", len(data)),
|
||||
)
|
||||
}
|
||||
|
||||
// loadInitial downloads and loads the initial database.
|
||||
// Returns an error if the download or parse fails.
|
||||
func (u *updater) loadInitial(ctx context.Context) error {
|
||||
u.logger.Debug("downloading initial database")
|
||||
|
||||
data, err := u.downloader.download(ctx, u.url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
reader, err := maxminddb.FromBytes(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
u.db.Store(reader)
|
||||
u.lastUpdated.Store(time.Now().UnixNano())
|
||||
|
||||
u.logger.Info("initial database loaded",
|
||||
slog.Int("size_bytes", len(data)),
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LastUpdated returns the time when the database was last updated.
|
||||
func (u *updater) LastUpdated() time.Time {
|
||||
ts := u.lastUpdated.Load()
|
||||
if ts == 0 {
|
||||
return time.Time{}
|
||||
}
|
||||
return time.Unix(0, ts)
|
||||
}
|
||||
|
||||
// IsUpdating returns true if an update is currently in progress.
|
||||
func (u *updater) IsUpdating() bool {
|
||||
return u.updating.Load()
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Package notify sends optional Telegram messages on check start/finish.
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Telegram is a minimal Bot API sendMessage client.
|
||||
type Telegram struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// New returns a Telegram notifier.
|
||||
func New() *Telegram {
|
||||
return &Telegram{client: &http.Client{Timeout: 10 * time.Second}}
|
||||
}
|
||||
|
||||
// Send posts a message. A blank token or chatID is a no-op (returns nil) so the
|
||||
// caller need not check whether notifications are configured.
|
||||
func (t *Telegram) Send(ctx context.Context, token, chatID, text string) error {
|
||||
if strings.TrimSpace(token) == "" || strings.TrimSpace(chatID) == "" {
|
||||
return nil
|
||||
}
|
||||
endpoint := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", token)
|
||||
form := url.Values{}
|
||||
form.Set("chat_id", chatID)
|
||||
form.Set("text", text)
|
||||
form.Set("parse_mode", "HTML")
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint,
|
||||
strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := t.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return fmt.Errorf("telegram: status %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
// Package subs turns a set of working proxies (from the current session) into a
|
||||
// subscription payload: it applies the unique_ips collapse, sorting and top-N
|
||||
// limit, then renders plain / base64 / Clash YAML / sing-box JSON.
|
||||
package subs
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// node is a protocol-agnostic view of a proxy, parsed from its canonical URL and
|
||||
// used by the Clash / sing-box renderers.
|
||||
type node struct {
|
||||
Name string
|
||||
Protocol string // vless | vmess | trojan | ss
|
||||
Server string
|
||||
Port int
|
||||
|
||||
UUID string // vless / vmess
|
||||
Password string // trojan / ss
|
||||
Method string // ss
|
||||
AlterID int // vmess
|
||||
Cipher string // vmess scy
|
||||
|
||||
TLS bool
|
||||
Reality bool
|
||||
SNI string
|
||||
ALPN []string
|
||||
Fingerprint string
|
||||
PublicKey string // reality pbk
|
||||
ShortID string // reality sid
|
||||
Flow string
|
||||
Insecure bool
|
||||
|
||||
Network string // tcp | ws | grpc
|
||||
Path string
|
||||
Host string // ws host header
|
||||
ServiceName string // grpc
|
||||
}
|
||||
|
||||
// parseNode decodes a canonical proxy URL into a node. name is the display label.
|
||||
func parseNode(canonical, name string) (*node, bool) {
|
||||
switch {
|
||||
case strings.HasPrefix(canonical, "vless://"):
|
||||
return parseVLESSNode(canonical, name)
|
||||
case strings.HasPrefix(canonical, "trojan://"):
|
||||
return parseTrojanNode(canonical, name)
|
||||
case strings.HasPrefix(canonical, "ss://"):
|
||||
return parseSSNode(canonical, name)
|
||||
case strings.HasPrefix(canonical, "vmess://"):
|
||||
return parseVMessNode(canonical, name)
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func splitALPN(s string) []string {
|
||||
if 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)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseVLESSNode(canonical, name string) (*node, bool) {
|
||||
u, err := url.Parse(canonical)
|
||||
if err != nil || u.User == nil {
|
||||
return nil, false
|
||||
}
|
||||
port, _ := strconv.Atoi(u.Port())
|
||||
q := u.Query()
|
||||
sec := q.Get("security")
|
||||
n := &node{
|
||||
Name: name,
|
||||
Protocol: "vless",
|
||||
Server: u.Hostname(),
|
||||
Port: port,
|
||||
UUID: u.User.Username(),
|
||||
TLS: sec == "tls" || sec == "reality",
|
||||
Reality: sec == "reality",
|
||||
SNI: q.Get("sni"),
|
||||
ALPN: splitALPN(q.Get("alpn")),
|
||||
Fingerprint: q.Get("fp"),
|
||||
PublicKey: q.Get("pbk"),
|
||||
ShortID: q.Get("sid"),
|
||||
Flow: q.Get("flow"),
|
||||
Insecure: q.Get("allowInsecure") == "1" || q.Get("insecure") == "1",
|
||||
Network: normNet(q.Get("type")),
|
||||
Path: q.Get("path"),
|
||||
Host: q.Get("host"),
|
||||
ServiceName: q.Get("serviceName"),
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
|
||||
func parseTrojanNode(canonical, name string) (*node, bool) {
|
||||
u, err := url.Parse(canonical)
|
||||
if err != nil || u.User == nil {
|
||||
return nil, false
|
||||
}
|
||||
port, _ := strconv.Atoi(u.Port())
|
||||
q := u.Query()
|
||||
sec := q.Get("security")
|
||||
n := &node{
|
||||
Name: name,
|
||||
Protocol: "trojan",
|
||||
Server: u.Hostname(),
|
||||
Port: port,
|
||||
Password: u.User.Username(),
|
||||
TLS: sec != "none", // trojan defaults to TLS
|
||||
SNI: q.Get("sni"),
|
||||
ALPN: splitALPN(q.Get("alpn")),
|
||||
Fingerprint: q.Get("fp"),
|
||||
Insecure: q.Get("allowInsecure") == "1",
|
||||
Network: normNet(q.Get("type")),
|
||||
Path: q.Get("path"),
|
||||
Host: q.Get("host"),
|
||||
ServiceName: q.Get("serviceName"),
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
|
||||
func parseSSNode(canonical, name string) (*node, bool) {
|
||||
u, err := url.Parse(canonical)
|
||||
if err != nil || u.User == nil {
|
||||
return nil, false
|
||||
}
|
||||
port, _ := strconv.Atoi(u.Port())
|
||||
method, password := decodeSSUser(u.User.String())
|
||||
if method == "" {
|
||||
return nil, false
|
||||
}
|
||||
return &node{
|
||||
Name: name,
|
||||
Protocol: "ss",
|
||||
Server: u.Hostname(),
|
||||
Port: port,
|
||||
Method: method,
|
||||
Password: password,
|
||||
Network: "tcp",
|
||||
}, true
|
||||
}
|
||||
|
||||
func parseVMessNode(canonical, name string) (*node, bool) {
|
||||
encoded := strings.TrimPrefix(canonical, "vmess://")
|
||||
raw, err := decodeAnyBase64(encoded)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
var v map[string]any
|
||||
if err := json.Unmarshal(raw, &v); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
port, _ := strconv.Atoi(str(v["port"]))
|
||||
aid, _ := strconv.Atoi(str(v["aid"]))
|
||||
cipher := str(v["scy"])
|
||||
if cipher == "" {
|
||||
cipher = "auto"
|
||||
}
|
||||
return &node{
|
||||
Name: name,
|
||||
Protocol: "vmess",
|
||||
Server: str(v["add"]),
|
||||
Port: port,
|
||||
UUID: str(v["id"]),
|
||||
AlterID: aid,
|
||||
Cipher: cipher,
|
||||
TLS: str(v["tls"]) == "tls",
|
||||
SNI: firstNonEmpty(str(v["sni"]), str(v["host"])),
|
||||
ALPN: splitALPN(str(v["alpn"])),
|
||||
Network: normNet(str(v["net"])),
|
||||
Path: str(v["path"]),
|
||||
Host: str(v["host"]),
|
||||
}, true
|
||||
}
|
||||
|
||||
func normNet(t string) string {
|
||||
switch t {
|
||||
case "", "tcp", "raw":
|
||||
return "tcp"
|
||||
default:
|
||||
return t
|
||||
}
|
||||
}
|
||||
|
||||
func str(v any) string {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return t
|
||||
case float64:
|
||||
if t == float64(int64(t)) {
|
||||
return strconv.FormatInt(int64(t), 10)
|
||||
}
|
||||
return strconv.FormatFloat(t, 'f', -1, 64)
|
||||
case bool:
|
||||
return strconv.FormatBool(t)
|
||||
case nil:
|
||||
return ""
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func firstNonEmpty(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func decodeAnyBase64(s string) ([]byte, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
for _, enc := range []*base64.Encoding{
|
||||
base64.StdEncoding, base64.RawStdEncoding,
|
||||
base64.URLEncoding, base64.RawURLEncoding,
|
||||
} {
|
||||
if b, err := enc.DecodeString(s); err == nil {
|
||||
return b, nil
|
||||
}
|
||||
}
|
||||
return nil, errInvalidBase64
|
||||
}
|
||||
|
||||
func decodeSSUser(userInfo string) (method, password string) {
|
||||
if dec, err := base64.RawURLEncoding.DecodeString(userInfo); err == nil {
|
||||
if i := strings.IndexByte(string(dec), ':'); i > 0 {
|
||||
return string(dec)[:i], string(dec)[i+1:]
|
||||
}
|
||||
}
|
||||
if dec, err := base64.StdEncoding.DecodeString(userInfo); err == nil {
|
||||
if i := strings.IndexByte(string(dec), ':'); i > 0 {
|
||||
return string(dec)[:i], string(dec)[i+1:]
|
||||
}
|
||||
}
|
||||
if i := strings.IndexByte(userInfo, ':'); i > 0 {
|
||||
return userInfo[:i], userInfo[i+1:]
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
var errInvalidBase64 = errBase64{}
|
||||
|
||||
type errBase64 struct{}
|
||||
|
||||
func (errBase64) Error() string { return "invalid base64" }
|
||||
@@ -0,0 +1,155 @@
|
||||
package subs
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.qomar.pw/omar/zhguchiy_perchik/internal/db"
|
||||
)
|
||||
|
||||
// Options controls how a proxy set is turned into a subscription payload.
|
||||
type Options struct {
|
||||
Format string // plain | base64 | clash | singbox
|
||||
UniqueIPs bool // collapse proxies sharing one exit IP
|
||||
UniqueIPsMetric string // speed | latency (winner selection)
|
||||
SortBy []string // e.g. ["speed:desc","latency:asc","country:asc"]
|
||||
Limit int // top-N (0 = no limit)
|
||||
}
|
||||
|
||||
// Build applies unique_ips → sort → limit and renders the requested format.
|
||||
// Order matters: the limit counts already-collapsed proxies (per the spec).
|
||||
func Build(proxies []db.SessionProxy, opt Options) (body, contentType string) {
|
||||
items := proxies
|
||||
if opt.UniqueIPs {
|
||||
items = collapseUniqueIPs(items, opt.UniqueIPsMetric)
|
||||
}
|
||||
sortProxies(items, opt.SortBy)
|
||||
if opt.Limit > 0 && len(items) > opt.Limit {
|
||||
items = items[:opt.Limit]
|
||||
}
|
||||
return render(items, opt.Format)
|
||||
}
|
||||
|
||||
// CollapseUniqueIPs is the exported unique_ips collapse used by the proxies list
|
||||
// endpoint (the subscription path uses it internally via Build).
|
||||
func CollapseUniqueIPs(proxies []db.SessionProxy, metric string) []db.SessionProxy {
|
||||
return collapseUniqueIPs(proxies, metric)
|
||||
}
|
||||
|
||||
// collapseUniqueIPs keeps one winner per exit IP. Winner is chosen by metric
|
||||
// (speed → max Mbps, latency → min ms); tie-break is always lower latency.
|
||||
// Proxies with an unknown exit IP are kept as-is (cannot be de-duplicated).
|
||||
func collapseUniqueIPs(proxies []db.SessionProxy, metric string) []db.SessionProxy {
|
||||
best := make(map[string]db.SessionProxy)
|
||||
var passthrough []db.SessionProxy
|
||||
order := make([]string, 0)
|
||||
|
||||
for _, p := range proxies {
|
||||
if p.ExitIP == "" {
|
||||
passthrough = append(passthrough, p)
|
||||
continue
|
||||
}
|
||||
cur, ok := best[p.ExitIP]
|
||||
if !ok {
|
||||
best[p.ExitIP] = p
|
||||
order = append(order, p.ExitIP)
|
||||
continue
|
||||
}
|
||||
if betterWinner(p, cur, metric) {
|
||||
best[p.ExitIP] = p
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]db.SessionProxy, 0, len(order)+len(passthrough))
|
||||
for _, ip := range order {
|
||||
out = append(out, best[ip])
|
||||
}
|
||||
return append(out, passthrough...)
|
||||
}
|
||||
|
||||
// betterWinner reports whether candidate beats current under the metric.
|
||||
func betterWinner(cand, cur db.SessionProxy, metric string) bool {
|
||||
if metric == "latency" {
|
||||
if cand.LatencyMs != cur.LatencyMs {
|
||||
return cand.LatencyMs < cur.LatencyMs
|
||||
}
|
||||
return cand.SpeedMbps > cur.SpeedMbps
|
||||
}
|
||||
// default: speed, tie-break lower latency
|
||||
if cand.SpeedMbps != cur.SpeedMbps {
|
||||
return cand.SpeedMbps > cur.SpeedMbps
|
||||
}
|
||||
return cand.LatencyMs < cur.LatencyMs
|
||||
}
|
||||
|
||||
// sortProxies applies a multi-key sort. Keys look like "speed:desc". Unknown
|
||||
// keys are ignored. With no keys, defaults to speed desc then latency asc.
|
||||
func sortProxies(proxies []db.SessionProxy, keys []string) {
|
||||
if len(keys) == 0 {
|
||||
keys = []string{"speed:desc", "latency:asc"}
|
||||
}
|
||||
sort.SliceStable(proxies, func(i, j int) bool {
|
||||
a, b := proxies[i], proxies[j]
|
||||
for _, key := range keys {
|
||||
field, desc := parseSortKey(key)
|
||||
c := compareField(a, b, field)
|
||||
if c == 0 {
|
||||
continue
|
||||
}
|
||||
if desc {
|
||||
return c > 0
|
||||
}
|
||||
return c < 0
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
func parseSortKey(key string) (field string, desc bool) {
|
||||
parts := strings.SplitN(strings.TrimSpace(key), ":", 2)
|
||||
field = strings.ToLower(parts[0])
|
||||
if len(parts) == 2 && strings.EqualFold(parts[1], "desc") {
|
||||
desc = true
|
||||
}
|
||||
return field, desc
|
||||
}
|
||||
|
||||
// compareField returns -1/0/1 comparing a to b on the given field.
|
||||
func compareField(a, b db.SessionProxy, field string) int {
|
||||
switch field {
|
||||
case "speed", "speed_mbps":
|
||||
return cmpFloat(a.SpeedMbps, b.SpeedMbps)
|
||||
case "latency", "latency_ms":
|
||||
return cmpInt(a.LatencyMs, b.LatencyMs)
|
||||
case "country":
|
||||
return strings.Compare(a.Country, b.Country)
|
||||
case "protocol":
|
||||
return strings.Compare(a.Protocol, b.Protocol)
|
||||
case "source":
|
||||
return strings.Compare(a.SourceName, b.SourceName)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func cmpFloat(a, b float64) int {
|
||||
switch {
|
||||
case a < b:
|
||||
return -1
|
||||
case a > b:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func cmpInt(a, b int) int {
|
||||
switch {
|
||||
case a < b:
|
||||
return -1
|
||||
case a > b:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
package subs
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"git.qomar.pw/omar/zhguchiy_perchik/internal/db"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// render produces the subscription body + content type for the given format.
|
||||
func render(proxies []db.SessionProxy, format string) (string, string) {
|
||||
switch strings.ToLower(format) {
|
||||
case "base64":
|
||||
return renderBase64(proxies), "text/plain; charset=utf-8"
|
||||
case "clash":
|
||||
return renderClash(proxies), "text/yaml; charset=utf-8"
|
||||
case "singbox":
|
||||
return renderSingbox(proxies), "application/json; charset=utf-8"
|
||||
default: // plain
|
||||
return renderPlain(proxies), "text/plain; charset=utf-8"
|
||||
}
|
||||
}
|
||||
|
||||
func renderPlain(proxies []db.SessionProxy) string {
|
||||
var b strings.Builder
|
||||
for i, p := range proxies {
|
||||
b.WriteString(labeledURL(p, labelFor(p, i)))
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func renderBase64(proxies []db.SessionProxy) string {
|
||||
return base64.StdEncoding.EncodeToString([]byte(renderPlain(proxies)))
|
||||
}
|
||||
|
||||
// labelFor builds a stable, unique display name for a proxy.
|
||||
func labelFor(p db.SessionProxy, idx int) string {
|
||||
c := p.Country
|
||||
if c == "" {
|
||||
c = "XX"
|
||||
}
|
||||
return fmt.Sprintf("%s-%s-%d", c, p.Protocol, idx+1)
|
||||
}
|
||||
|
||||
// labeledURL re-attaches a display label to a canonical URL. For vmess the label
|
||||
// goes into the ps field; for the others it becomes the URL fragment.
|
||||
func labeledURL(p db.SessionProxy, name string) string {
|
||||
if strings.HasPrefix(p.CanonicalURL, "vmess://") {
|
||||
return vmessWithPS(p.CanonicalURL, name)
|
||||
}
|
||||
return p.CanonicalURL + "#" + url.PathEscape(name)
|
||||
}
|
||||
|
||||
func vmessWithPS(canonical, name string) string {
|
||||
encoded := strings.TrimPrefix(canonical, "vmess://")
|
||||
raw, err := decodeAnyBase64(encoded)
|
||||
if err != nil {
|
||||
return canonical
|
||||
}
|
||||
var v map[string]any
|
||||
if err := json.Unmarshal(raw, &v); err != nil {
|
||||
return canonical
|
||||
}
|
||||
v["ps"] = name
|
||||
out, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return canonical
|
||||
}
|
||||
return "vmess://" + base64.StdEncoding.EncodeToString(out)
|
||||
}
|
||||
|
||||
// --- Clash ----------------------------------------------------------------
|
||||
|
||||
func renderClash(proxies []db.SessionProxy) string {
|
||||
clashProxies := make([]map[string]any, 0, len(proxies))
|
||||
names := make([]string, 0, len(proxies))
|
||||
for i, p := range proxies {
|
||||
name := labelFor(p, i)
|
||||
n, ok := parseNode(p.CanonicalURL, name)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
m := clashProxy(n)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
clashProxies = append(clashProxies, m)
|
||||
names = append(names, name)
|
||||
}
|
||||
|
||||
doc := map[string]any{
|
||||
"proxies": clashProxies,
|
||||
"proxy-groups": []map[string]any{
|
||||
{"name": "PROXY", "type": "select", "proxies": append([]string{"AUTO"}, names...)},
|
||||
{"name": "AUTO", "type": "url-test", "proxies": names,
|
||||
"url": "http://www.gstatic.com/generate_204", "interval": 300},
|
||||
},
|
||||
"rules": []string{"MATCH,PROXY"},
|
||||
}
|
||||
out, err := yaml.Marshal(doc)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func clashProxy(n *node) map[string]any {
|
||||
m := map[string]any{
|
||||
"name": n.Name,
|
||||
"server": n.Server,
|
||||
"port": n.Port,
|
||||
}
|
||||
switch n.Protocol {
|
||||
case "vless":
|
||||
m["type"] = "vless"
|
||||
m["uuid"] = n.UUID
|
||||
m["udp"] = true
|
||||
if n.Flow != "" {
|
||||
m["flow"] = n.Flow
|
||||
}
|
||||
applyClashTLS(m, n)
|
||||
applyClashTransport(m, n)
|
||||
case "vmess":
|
||||
m["type"] = "vmess"
|
||||
m["uuid"] = n.UUID
|
||||
m["alterId"] = n.AlterID
|
||||
m["cipher"] = n.Cipher
|
||||
m["udp"] = true
|
||||
applyClashTLS(m, n)
|
||||
applyClashTransport(m, n)
|
||||
case "trojan":
|
||||
m["type"] = "trojan"
|
||||
m["password"] = n.Password
|
||||
m["udp"] = true
|
||||
if n.SNI != "" {
|
||||
m["sni"] = n.SNI
|
||||
}
|
||||
if n.Insecure {
|
||||
m["skip-cert-verify"] = true
|
||||
}
|
||||
if len(n.ALPN) > 0 {
|
||||
m["alpn"] = n.ALPN
|
||||
}
|
||||
applyClashTransport(m, n)
|
||||
case "ss":
|
||||
m["type"] = "ss"
|
||||
m["cipher"] = n.Method
|
||||
m["password"] = n.Password
|
||||
m["udp"] = true
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func applyClashTLS(m map[string]any, n *node) {
|
||||
if !n.TLS {
|
||||
return
|
||||
}
|
||||
m["tls"] = true
|
||||
if n.SNI != "" {
|
||||
m["servername"] = n.SNI
|
||||
}
|
||||
if n.Fingerprint != "" {
|
||||
m["client-fingerprint"] = n.Fingerprint
|
||||
}
|
||||
if len(n.ALPN) > 0 {
|
||||
m["alpn"] = n.ALPN
|
||||
}
|
||||
if n.Insecure {
|
||||
m["skip-cert-verify"] = true
|
||||
}
|
||||
if n.Reality {
|
||||
ro := map[string]any{"public-key": n.PublicKey}
|
||||
if n.ShortID != "" {
|
||||
ro["short-id"] = n.ShortID
|
||||
}
|
||||
m["reality-opts"] = ro
|
||||
}
|
||||
}
|
||||
|
||||
func applyClashTransport(m map[string]any, n *node) {
|
||||
switch n.Network {
|
||||
case "ws":
|
||||
m["network"] = "ws"
|
||||
opts := map[string]any{}
|
||||
if n.Path != "" {
|
||||
opts["path"] = n.Path
|
||||
}
|
||||
if n.Host != "" {
|
||||
opts["headers"] = map[string]any{"Host": n.Host}
|
||||
}
|
||||
m["ws-opts"] = opts
|
||||
case "grpc":
|
||||
m["network"] = "grpc"
|
||||
if n.ServiceName != "" {
|
||||
m["grpc-opts"] = map[string]any{"grpc-service-name": n.ServiceName}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- sing-box -------------------------------------------------------------
|
||||
|
||||
func renderSingbox(proxies []db.SessionProxy) string {
|
||||
outbounds := make([]map[string]any, 0, len(proxies)+2)
|
||||
names := make([]string, 0, len(proxies))
|
||||
for i, p := range proxies {
|
||||
name := labelFor(p, i)
|
||||
n, ok := parseNode(p.CanonicalURL, name)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
ob := singboxOutbound(n)
|
||||
if ob == nil {
|
||||
continue
|
||||
}
|
||||
outbounds = append(outbounds, ob)
|
||||
names = append(names, name)
|
||||
}
|
||||
outbounds = append(outbounds,
|
||||
map[string]any{"type": "selector", "tag": "proxy",
|
||||
"outbounds": append([]string{"auto"}, names...)},
|
||||
map[string]any{"type": "urltest", "tag": "auto", "outbounds": names,
|
||||
"url": "http://www.gstatic.com/generate_204", "interval": "5m"},
|
||||
)
|
||||
doc := map[string]any{"outbounds": outbounds}
|
||||
out, err := json.MarshalIndent(doc, "", " ")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func singboxOutbound(n *node) map[string]any {
|
||||
ob := map[string]any{
|
||||
"tag": n.Name,
|
||||
"server": n.Server,
|
||||
"server_port": n.Port,
|
||||
}
|
||||
switch n.Protocol {
|
||||
case "vless":
|
||||
ob["type"] = "vless"
|
||||
ob["uuid"] = n.UUID
|
||||
if n.Flow != "" {
|
||||
ob["flow"] = n.Flow
|
||||
}
|
||||
applySingboxTLS(ob, n)
|
||||
applySingboxTransport(ob, n)
|
||||
case "vmess":
|
||||
ob["type"] = "vmess"
|
||||
ob["uuid"] = n.UUID
|
||||
ob["alter_id"] = n.AlterID
|
||||
ob["security"] = n.Cipher
|
||||
applySingboxTLS(ob, n)
|
||||
applySingboxTransport(ob, n)
|
||||
case "trojan":
|
||||
ob["type"] = "trojan"
|
||||
ob["password"] = n.Password
|
||||
applySingboxTLS(ob, n)
|
||||
applySingboxTransport(ob, n)
|
||||
case "ss":
|
||||
ob["type"] = "shadowsocks"
|
||||
ob["method"] = n.Method
|
||||
ob["password"] = n.Password
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
return ob
|
||||
}
|
||||
|
||||
func applySingboxTLS(ob map[string]any, n *node) {
|
||||
if !n.TLS {
|
||||
return
|
||||
}
|
||||
tls := map[string]any{"enabled": true, "insecure": n.Insecure}
|
||||
if n.SNI != "" {
|
||||
tls["server_name"] = n.SNI
|
||||
}
|
||||
if len(n.ALPN) > 0 {
|
||||
tls["alpn"] = n.ALPN
|
||||
}
|
||||
if n.Fingerprint != "" {
|
||||
tls["utls"] = map[string]any{"enabled": true, "fingerprint": n.Fingerprint}
|
||||
}
|
||||
if n.Reality {
|
||||
reality := map[string]any{"enabled": true, "public_key": n.PublicKey}
|
||||
if n.ShortID != "" {
|
||||
reality["short_id"] = n.ShortID
|
||||
}
|
||||
tls["reality"] = reality
|
||||
}
|
||||
ob["tls"] = tls
|
||||
}
|
||||
|
||||
func applySingboxTransport(ob map[string]any, n *node) {
|
||||
switch n.Network {
|
||||
case "ws":
|
||||
tr := map[string]any{"type": "ws"}
|
||||
if n.Path != "" {
|
||||
tr["path"] = n.Path
|
||||
}
|
||||
if n.Host != "" {
|
||||
tr["headers"] = map[string]any{"Host": n.Host}
|
||||
}
|
||||
ob["transport"] = tr
|
||||
case "grpc":
|
||||
tr := map[string]any{"type": "grpc"}
|
||||
if n.ServiceName != "" {
|
||||
tr["service_name"] = n.ServiceName
|
||||
}
|
||||
ob["transport"] = tr
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package subs
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.qomar.pw/omar/zhguchiy_perchik/internal/db"
|
||||
)
|
||||
|
||||
func TestCollapseUniqueIPs(t *testing.T) {
|
||||
in := []db.SessionProxy{
|
||||
{ProxyID: 1, ExitIP: "1.1.1.1", SpeedMbps: 10, LatencyMs: 200},
|
||||
{ProxyID: 2, ExitIP: "1.1.1.1", SpeedMbps: 25, LatencyMs: 300}, // faster winner
|
||||
{ProxyID: 3, ExitIP: "2.2.2.2", SpeedMbps: 5, LatencyMs: 100},
|
||||
{ProxyID: 4, ExitIP: "", SpeedMbps: 1, LatencyMs: 50}, // unknown ip: passthrough
|
||||
}
|
||||
out := CollapseUniqueIPs(in, "speed")
|
||||
if len(out) != 3 {
|
||||
t.Fatalf("want 3, got %d", len(out))
|
||||
}
|
||||
// winner for 1.1.1.1 must be proxy 2 (higher speed)
|
||||
for _, p := range out {
|
||||
if p.ExitIP == "1.1.1.1" && p.ProxyID != 2 {
|
||||
t.Errorf("speed winner: want proxy 2, got %d", p.ProxyID)
|
||||
}
|
||||
}
|
||||
|
||||
outLat := CollapseUniqueIPs(in, "latency")
|
||||
for _, p := range outLat {
|
||||
if p.ExitIP == "1.1.1.1" && p.ProxyID != 1 {
|
||||
t.Errorf("latency winner: want proxy 1 (lower ms), got %d", p.ProxyID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFormats(t *testing.T) {
|
||||
items := []db.SessionProxy{
|
||||
{CanonicalURL: "vless://uuid@h.com:443?security=tls&sni=a.com&type=ws",
|
||||
Protocol: "vless", Country: "US", ExitIP: "1.1.1.1", SpeedMbps: 20, LatencyMs: 100},
|
||||
{CanonicalURL: "trojan://pass@t.com:443?sni=b.com",
|
||||
Protocol: "trojan", Country: "DE", ExitIP: "2.2.2.2", SpeedMbps: 10, LatencyMs: 150},
|
||||
}
|
||||
|
||||
plain, ct := Build(items, Options{Format: "plain"})
|
||||
if !strings.Contains(ct, "text/plain") {
|
||||
t.Errorf("plain content type: %s", ct)
|
||||
}
|
||||
if lines := strings.Count(strings.TrimSpace(plain), "\n"); lines != 1 {
|
||||
t.Errorf("plain want 2 lines, got %d newlines", lines)
|
||||
}
|
||||
|
||||
b64, _ := Build(items, Options{Format: "base64"})
|
||||
if _, err := base64.StdEncoding.DecodeString(strings.TrimSpace(b64)); err != nil {
|
||||
t.Errorf("base64 not decodable: %v", err)
|
||||
}
|
||||
|
||||
clash, ctc := Build(items, Options{Format: "clash"})
|
||||
if !strings.Contains(ctc, "yaml") || !strings.Contains(clash, "proxies:") {
|
||||
t.Errorf("clash output malformed: %s", clash[:min(80, len(clash))])
|
||||
}
|
||||
|
||||
sb, cts := Build(items, Options{Format: "singbox"})
|
||||
if !strings.Contains(cts, "json") || !strings.Contains(sb, "outbounds") {
|
||||
t.Errorf("singbox output malformed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildLimitAfterCollapse(t *testing.T) {
|
||||
items := []db.SessionProxy{
|
||||
{CanonicalURL: "vless://a@h1:443", Protocol: "vless", ExitIP: "1.1.1.1", SpeedMbps: 5},
|
||||
{CanonicalURL: "vless://b@h2:443", Protocol: "vless", ExitIP: "1.1.1.1", SpeedMbps: 9},
|
||||
{CanonicalURL: "vless://c@h3:443", Protocol: "vless", ExitIP: "2.2.2.2", SpeedMbps: 7},
|
||||
}
|
||||
// unique collapses to 2; limit 5 keeps both.
|
||||
out, _ := Build(items, Options{Format: "plain", UniqueIPs: true, Limit: 5})
|
||||
if n := strings.Count(strings.TrimSpace(out), "\n"); n != 1 {
|
||||
t.Errorf("want 2 collapsed lines, got %d newlines", n)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package upstream
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// SOCKS5 address types.
|
||||
const (
|
||||
AtypIPv4 byte = 0x01
|
||||
AtypDomain byte = 0x03
|
||||
AtypIPv6 byte = 0x04
|
||||
)
|
||||
|
||||
// EncodeAddress builds a SOCKS5-style "[ATYP][ADDR][PORT]" header for the
|
||||
// given "host:port" destination. Numeric IPs become IPv4/IPv6 records;
|
||||
// names become domain records.
|
||||
func EncodeAddress(hostPort string) ([]byte, error) {
|
||||
host, portStr, err := net.SplitHostPort(hostPort)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("address: %w", err)
|
||||
}
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("address: bad port: %w", err)
|
||||
}
|
||||
if port <= 0 || port > 65535 {
|
||||
return nil, fmt.Errorf("address: port out of range: %d", port)
|
||||
}
|
||||
|
||||
var buf []byte
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
if v4 := ip.To4(); v4 != nil {
|
||||
buf = make([]byte, 1+4+2)
|
||||
buf[0] = AtypIPv4
|
||||
copy(buf[1:5], v4)
|
||||
binary.BigEndian.PutUint16(buf[5:], uint16(port))
|
||||
} else {
|
||||
v6 := ip.To16()
|
||||
buf = make([]byte, 1+16+2)
|
||||
buf[0] = AtypIPv6
|
||||
copy(buf[1:17], v6)
|
||||
binary.BigEndian.PutUint16(buf[17:], uint16(port))
|
||||
}
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
if len(host) > 255 {
|
||||
return nil, fmt.Errorf("address: domain too long (%d bytes)", len(host))
|
||||
}
|
||||
buf = make([]byte, 1+1+len(host)+2)
|
||||
buf[0] = AtypDomain
|
||||
buf[1] = byte(len(host))
|
||||
copy(buf[2:2+len(host)], host)
|
||||
binary.BigEndian.PutUint16(buf[2+len(host):], uint16(port))
|
||||
return buf, nil
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
package upstream
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ErrEmptyLine is returned by Canonicalize for blank / comment-only input.
|
||||
var ErrEmptyLine = errors.New("empty line")
|
||||
|
||||
// Per-protocol query whitelists: only parameters that actually influence the
|
||||
// connection are kept. Everything else (ads, labels, tracking) is dropped so it
|
||||
// cannot fracture the canonical form. Keys are matched case-sensitively as they
|
||||
// appear in v2ray share links.
|
||||
var (
|
||||
vlessParams = set(
|
||||
"type", "security", "encryption", "sni", "fp", "path", "host",
|
||||
"pbk", "sid", "flow", "headerType", "alpn", "mode", "spx",
|
||||
"serviceName", "allowInsecure", "insecure", "packetEncoding",
|
||||
"authority", "extra", "ech", "quicSecurity",
|
||||
"x_padding_bytes", "pcs", "pqv", "fm", "ed", "eh",
|
||||
)
|
||||
trojanParams = set(
|
||||
"sni", "type", "security", "path", "host", "allowInsecure", "fp",
|
||||
"alpn", "headerType", "mode", "serviceName", "sid", "pbk", "spx", "peer",
|
||||
)
|
||||
ssParams = set("plugin")
|
||||
// vmess json fields to keep (drop ps label and junk like name/nation/deviceID).
|
||||
vmessFields = set("add", "port", "id", "aid", "net", "type", "host",
|
||||
"path", "tls", "sni", "alpn", "fp", "scy", "v")
|
||||
)
|
||||
|
||||
func set(keys ...string) map[string]struct{} {
|
||||
m := make(map[string]struct{}, len(keys))
|
||||
for _, k := range keys {
|
||||
m[k] = struct{}{}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// Canonicalize cleans a raw source line and produces a stable canonical URL plus
|
||||
// the parsed Upstream. Two share links describing the same proxy (different
|
||||
// param order, ad params, HTML-mangled ampersands, label fragments) collapse to
|
||||
// the same canonical string. Broken/unparseable lines return an error.
|
||||
func Canonicalize(raw string) (string, *Upstream, error) {
|
||||
cleaned := preClean(raw)
|
||||
if cleaned == "" {
|
||||
return "", nil, ErrEmptyLine
|
||||
}
|
||||
|
||||
scheme := schemeOf(cleaned)
|
||||
var canon string
|
||||
var err error
|
||||
switch scheme {
|
||||
case "vless":
|
||||
canon, err = canonURLScheme(cleaned, "vless", vlessParams)
|
||||
case "trojan":
|
||||
canon, err = canonURLScheme(cleaned, "trojan", trojanParams)
|
||||
case "ss":
|
||||
canon, err = canonSS(cleaned)
|
||||
case "vmess":
|
||||
canon, err = canonVMess(cleaned)
|
||||
default:
|
||||
return "", nil, fmt.Errorf("unsupported scheme %q", scheme)
|
||||
}
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
up, err := ParseURL(canon)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("canonical did not re-parse: %w", err)
|
||||
}
|
||||
up.Raw = canon
|
||||
return canon, up, nil
|
||||
}
|
||||
|
||||
// preClean strips the label fragment, un-mangles HTML-escaped ampersands and
|
||||
// collapses broken query separators, all before parsing.
|
||||
func preClean(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || strings.HasPrefix(raw, "//") || strings.HasPrefix(raw, "#") {
|
||||
return ""
|
||||
}
|
||||
// Drop the label / ad fragment entirely.
|
||||
if i := strings.IndexByte(raw, '#'); i >= 0 {
|
||||
raw = raw[:i]
|
||||
}
|
||||
// Un-mangle & (repeat for &amp;) and the variant where the leading
|
||||
// & was lost entirely (bare "amp;").
|
||||
for strings.Contains(raw, "&") {
|
||||
raw = strings.ReplaceAll(raw, "&", "&")
|
||||
}
|
||||
raw = strings.ReplaceAll(raw, "amp;", "&")
|
||||
// Collapse broken separators produced by the un-mangling.
|
||||
raw = strings.ReplaceAll(raw, "&;", "&")
|
||||
raw = strings.ReplaceAll(raw, ";&", "&")
|
||||
for strings.Contains(raw, "&&") {
|
||||
raw = strings.ReplaceAll(raw, "&&", "&")
|
||||
}
|
||||
raw = strings.ReplaceAll(raw, "?&", "?")
|
||||
raw = strings.TrimRight(raw, "&;?")
|
||||
return strings.TrimSpace(raw)
|
||||
}
|
||||
|
||||
func schemeOf(raw string) string {
|
||||
if i := strings.Index(raw, "://"); i > 0 {
|
||||
return strings.ToLower(raw[:i])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// normHostPort lowercases the host, drops a trailing dot and rebuilds a safe
|
||||
// host:port (bracketing IPv6). Returns an error for a missing/invalid port.
|
||||
func normHostPort(u *url.URL) (string, string, int, error) {
|
||||
host := strings.ToLower(strings.TrimRight(u.Hostname(), "."))
|
||||
if host == "" {
|
||||
return "", "", 0, errors.New("missing host")
|
||||
}
|
||||
portStr := u.Port()
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil || port <= 0 || port > 65535 {
|
||||
return "", "", 0, fmt.Errorf("bad port %q", portStr)
|
||||
}
|
||||
return host, portStr, port, nil
|
||||
}
|
||||
|
||||
// canonURLScheme canonicalizes vless/trojan style share links: keep whitelisted
|
||||
// query params, sort them (url.Values.Encode sorts by key), normalize host.
|
||||
func canonURLScheme(cleaned, scheme string, allow map[string]struct{}) (string, error) {
|
||||
u, err := url.Parse(cleaned)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s: %w", scheme, err)
|
||||
}
|
||||
if u.User == nil || u.User.Username() == "" {
|
||||
return "", fmt.Errorf("%s: missing credentials", scheme)
|
||||
}
|
||||
host, portStr, _, err := normHostPort(u)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s: %w", scheme, err)
|
||||
}
|
||||
|
||||
out := url.Values{}
|
||||
for k, vs := range u.Query() {
|
||||
if _, ok := allow[k]; ok && len(vs) > 0 && vs[0] != "" {
|
||||
out.Set(k, vs[0])
|
||||
}
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(scheme)
|
||||
b.WriteString("://")
|
||||
b.WriteString(u.User.Username())
|
||||
b.WriteByte('@')
|
||||
b.WriteString(net.JoinHostPort(host, portStr))
|
||||
if enc := out.Encode(); enc != "" {
|
||||
b.WriteByte('?')
|
||||
b.WriteString(enc)
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// canonSS canonicalizes ss:// links to ss://base64url(method:password)@host:port[?plugin=…].
|
||||
func canonSS(cleaned string) (string, error) {
|
||||
u, err := url.Parse(cleaned)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("ss: %w", err)
|
||||
}
|
||||
if u.User == nil {
|
||||
return "", errors.New("ss: missing userinfo")
|
||||
}
|
||||
method, password := decodeSSUser(u.User.String())
|
||||
if method == "" || password == "" {
|
||||
return "", errors.New("ss: missing credentials")
|
||||
}
|
||||
host, portStr, _, err := normHostPort(u)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("ss: %w", err)
|
||||
}
|
||||
userinfo := base64.RawURLEncoding.EncodeToString([]byte(method + ":" + password))
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("ss://")
|
||||
b.WriteString(userinfo)
|
||||
b.WriteByte('@')
|
||||
b.WriteString(net.JoinHostPort(host, portStr))
|
||||
if plugin := u.Query().Get("plugin"); plugin != "" {
|
||||
b.WriteString("?plugin=")
|
||||
b.WriteString(url.QueryEscape(plugin))
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
func decodeSSUser(userInfo string) (method, password string) {
|
||||
if dec, err := base64.RawURLEncoding.DecodeString(userInfo); err == nil {
|
||||
if m, p, ok := splitColon(string(dec)); ok {
|
||||
return m, p
|
||||
}
|
||||
}
|
||||
if dec, err := base64.StdEncoding.DecodeString(userInfo); err == nil {
|
||||
if m, p, ok := splitColon(string(dec)); ok {
|
||||
return m, p
|
||||
}
|
||||
}
|
||||
// cleartext method:password
|
||||
if m, p, ok := splitColon(userInfo); ok {
|
||||
return m, p
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func splitColon(s string) (string, string, bool) {
|
||||
i := strings.IndexByte(s, ':')
|
||||
if i <= 0 || i == len(s)-1 {
|
||||
return "", "", false
|
||||
}
|
||||
return s[:i], s[i+1:], true
|
||||
}
|
||||
|
||||
// canonVMess decodes the base64(json) blob, keeps whitelisted fields, and
|
||||
// re-encodes with alphabetically-sorted keys (all values as strings) so the
|
||||
// canonical form is stable regardless of original key order or numeric typing.
|
||||
func canonVMess(cleaned string) (string, error) {
|
||||
encoded := strings.TrimPrefix(cleaned, "vmess://")
|
||||
decoded, err := decodeAnyBase64(encoded)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("vmess: base64: %w", err)
|
||||
}
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal(decoded, &raw); err != nil {
|
||||
return "", fmt.Errorf("vmess: json: %w", err)
|
||||
}
|
||||
|
||||
kept := make(map[string]string)
|
||||
for k, v := range raw {
|
||||
if _, ok := vmessFields[k]; !ok {
|
||||
continue
|
||||
}
|
||||
// Drop empty values so an explicit "host":"" is equivalent to it being
|
||||
// absent — otherwise identical proxies would not collapse.
|
||||
if s := anyToString(v); s != "" {
|
||||
kept[k] = s
|
||||
}
|
||||
}
|
||||
if kept["add"] == "" || kept["port"] == "" || kept["id"] == "" {
|
||||
return "", errors.New("vmess: missing required fields")
|
||||
}
|
||||
kept["add"] = strings.ToLower(strings.TrimRight(kept["add"], "."))
|
||||
|
||||
// Marshal with sorted keys deterministically.
|
||||
keys := make([]string, 0, len(kept))
|
||||
for k := range kept {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
var sb strings.Builder
|
||||
sb.WriteByte('{')
|
||||
for i, k := range keys {
|
||||
if i > 0 {
|
||||
sb.WriteByte(',')
|
||||
}
|
||||
kb, _ := json.Marshal(k)
|
||||
vb, _ := json.Marshal(kept[k])
|
||||
sb.Write(kb)
|
||||
sb.WriteByte(':')
|
||||
sb.Write(vb)
|
||||
}
|
||||
sb.WriteByte('}')
|
||||
|
||||
return "vmess://" + base64.StdEncoding.EncodeToString([]byte(sb.String())), nil
|
||||
}
|
||||
|
||||
func decodeAnyBase64(s string) ([]byte, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
for _, enc := range []*base64.Encoding{
|
||||
base64.StdEncoding, base64.RawStdEncoding,
|
||||
base64.URLEncoding, base64.RawURLEncoding,
|
||||
} {
|
||||
if b, err := enc.DecodeString(s); err == nil {
|
||||
return b, nil
|
||||
}
|
||||
}
|
||||
return nil, errors.New("not base64")
|
||||
}
|
||||
|
||||
func anyToString(v any) string {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return t
|
||||
case float64:
|
||||
// Integers commonly arrive as float64 from encoding/json.
|
||||
if t == float64(int64(t)) {
|
||||
return strconv.FormatInt(int64(t), 10)
|
||||
}
|
||||
return strconv.FormatFloat(t, 'f', -1, 64)
|
||||
case bool:
|
||||
return strconv.FormatBool(t)
|
||||
case nil:
|
||||
return ""
|
||||
default:
|
||||
return fmt.Sprintf("%v", t)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package upstream
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCanonicalize_DedupEquivalence(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
a string
|
||||
b string
|
||||
}{
|
||||
{
|
||||
name: "vless param order + label + ad param",
|
||||
a: "vless://uuid-1@Example.COM:443?type=ws&security=tls&sni=a.com&Telegram=@ad#label",
|
||||
b: "vless://uuid-1@example.com:443?security=tls&sni=a.com&type=ws",
|
||||
},
|
||||
{
|
||||
name: "vless html-mangled ampersand",
|
||||
a: "vless://uuid-1@h.com:443?type=ws&security=tls",
|
||||
b: "vless://uuid-1@h.com:443?security=tls&type=ws",
|
||||
},
|
||||
{
|
||||
name: "vless bare amp; separator (lost &)",
|
||||
a: "vless://uuid-1@h.com:443?security=tlsamp;type=ws",
|
||||
b: "vless://uuid-1@h.com:443?security=tls&type=ws",
|
||||
},
|
||||
{
|
||||
name: "trojan trailing slash + fragment",
|
||||
a: "trojan://pass@h.com:443/?sni=x.com&type=tcp#name",
|
||||
b: "trojan://pass@h.com:443?sni=x.com&type=tcp",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ca, _, err := Canonicalize(tt.a)
|
||||
if err != nil {
|
||||
t.Fatalf("Canonicalize(a) error: %v", err)
|
||||
}
|
||||
cb, _, err := Canonicalize(tt.b)
|
||||
if err != nil {
|
||||
t.Fatalf("Canonicalize(b) error: %v", err)
|
||||
}
|
||||
if ca != cb {
|
||||
t.Errorf("canonical mismatch:\n a=%q -> %q\n b=%q -> %q", tt.a, ca, tt.b, cb)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanonicalize_Rejects(t *testing.T) {
|
||||
for _, in := range []string{
|
||||
"", " ", "# just a label", "not a url", "订阅内容解析错误",
|
||||
"hysteria2://x@h.com:443",
|
||||
} {
|
||||
if _, _, err := Canonicalize(in); err == nil {
|
||||
t.Errorf("expected error for %q", in)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanonicalize_VMessStableKeys(t *testing.T) {
|
||||
// Same vmess config, keys in different JSON order + junk fields, must match.
|
||||
a := "vmess://" + b64(`{"v":"2","ps":"label","add":"h.com","port":"443","id":"uuid","aid":"0","net":"ws","type":"none","host":"","path":"/","tls":"tls","scy":"auto","nation":"US"}`)
|
||||
b := "vmess://" + b64(`{"add":"h.com","aid":"0","id":"uuid","net":"ws","path":"/","port":"443","scy":"auto","tls":"tls","type":"none","v":"2"}`)
|
||||
ca, _, err := Canonicalize(a)
|
||||
if err != nil {
|
||||
t.Fatalf("a: %v", err)
|
||||
}
|
||||
cb, _, err := Canonicalize(b)
|
||||
if err != nil {
|
||||
t.Fatalf("b: %v", err)
|
||||
}
|
||||
if ca != cb {
|
||||
t.Errorf("vmess canonical mismatch:\n %q\n %q", ca, cb)
|
||||
}
|
||||
}
|
||||
|
||||
func b64(s string) string {
|
||||
return base64.StdEncoding.EncodeToString([]byte(s))
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
package upstream
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func ParseFile(path string, log *slog.Logger) ([]*Upstream, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var upstreams []*Upstream
|
||||
scanner := bufio.NewScanner(f)
|
||||
lineNum := 0
|
||||
for scanner.Scan() {
|
||||
lineNum++
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "//") {
|
||||
continue
|
||||
}
|
||||
|
||||
up, err := ParseURL(line)
|
||||
if err != nil {
|
||||
if log != NoneLogger {
|
||||
log.Debug("skipped upstream line", "line", lineNum, "err", err.Error())
|
||||
}
|
||||
continue
|
||||
}
|
||||
upstreams = append(upstreams, up)
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return upstreams, nil
|
||||
}
|
||||
|
||||
var NoneLogger *slog.Logger
|
||||
|
||||
func ParseURL(raw string) (*Upstream, error) {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switch u.Scheme {
|
||||
case "ss":
|
||||
return parseSS(u, raw)
|
||||
case "vless":
|
||||
return parseVLESS(u, raw)
|
||||
case "trojan":
|
||||
return parseTrojan(u, raw)
|
||||
case "vmess":
|
||||
return parseVMess(raw)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported scheme")
|
||||
}
|
||||
}
|
||||
|
||||
func parseSS(u *url.URL, raw string) (*Upstream, error) {
|
||||
// ss://BASE64@host:port#tag
|
||||
host, port, err := splitHostPort(u.Host)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ss: %w", err)
|
||||
}
|
||||
|
||||
var method, password string
|
||||
if u.User != nil {
|
||||
userInfo := u.User.String()
|
||||
// Check if it's base64(method:password)
|
||||
decoded, err := base64.RawURLEncoding.DecodeString(userInfo)
|
||||
if err != nil {
|
||||
decoded, err = base64.StdEncoding.DecodeString(userInfo)
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
parts := strings.SplitN(string(decoded), ":", 2)
|
||||
if len(parts) == 2 {
|
||||
method = parts[0]
|
||||
password = parts[1]
|
||||
}
|
||||
} else {
|
||||
// Try cleartext
|
||||
password, _ = u.User.Password()
|
||||
method = u.User.Username()
|
||||
}
|
||||
}
|
||||
|
||||
if method == "" || password == "" {
|
||||
return nil, fmt.Errorf("ss: missing credentials")
|
||||
}
|
||||
|
||||
return &Upstream{
|
||||
Scheme: SchemeShadowsocks,
|
||||
Host: host,
|
||||
Port: port,
|
||||
Tag: u.Fragment,
|
||||
Raw: raw,
|
||||
SS: &ShadowsocksConfig{
|
||||
Method: method,
|
||||
Password: password,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseVLESS(u *url.URL, raw string) (*Upstream, error) {
|
||||
// vless://uuid@host:port?query#tag
|
||||
host, port, err := splitHostPort(u.Host)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("vless: %w", err)
|
||||
}
|
||||
|
||||
q := u.Query()
|
||||
v := &VLESSConfig{
|
||||
UUID: u.User.Username(),
|
||||
Security: q.Get("security"),
|
||||
Encryption: q.Get("encryption"),
|
||||
Flow: q.Get("flow"),
|
||||
SNI: q.Get("sni"),
|
||||
Fingerprint: q.Get("fp"),
|
||||
PublicKey: q.Get("pbk"),
|
||||
ShortID: q.Get("sid"),
|
||||
SpiderX: q.Get("spx"),
|
||||
}
|
||||
|
||||
up := &Upstream{
|
||||
Scheme: SchemeVLESS,
|
||||
Host: host,
|
||||
Port: port,
|
||||
Tag: u.Fragment,
|
||||
Raw: raw,
|
||||
VLESS: v,
|
||||
Transport: q.Get("type"),
|
||||
Path: q.Get("path"),
|
||||
ServiceName: q.Get("serviceName"),
|
||||
}
|
||||
if up.Transport == "" || up.Transport == "tcp" || up.Transport == "raw" {
|
||||
up.Transport = "tcp"
|
||||
}
|
||||
return up, nil
|
||||
}
|
||||
|
||||
func parseTrojan(u *url.URL, raw string) (*Upstream, error) {
|
||||
// trojan://password@host:port?query#tag
|
||||
host, port, err := splitHostPort(u.Host)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("trojan: %w", err)
|
||||
}
|
||||
|
||||
q := u.Query()
|
||||
t := &TrojanConfig{
|
||||
Password: u.User.Username(),
|
||||
Security: q.Get("security"),
|
||||
SNI: q.Get("sni"),
|
||||
}
|
||||
|
||||
up := &Upstream{
|
||||
Scheme: SchemeTrojan,
|
||||
Host: host,
|
||||
Port: port,
|
||||
Tag: u.Fragment,
|
||||
Raw: raw,
|
||||
Trojan: t,
|
||||
Transport: q.Get("type"),
|
||||
Path: q.Get("path"),
|
||||
ServiceName: q.Get("serviceName"),
|
||||
}
|
||||
if up.Transport == "" || up.Transport == "tcp" || up.Transport == "raw" {
|
||||
up.Transport = "tcp"
|
||||
}
|
||||
return up, nil
|
||||
}
|
||||
|
||||
func splitHostPort(s string) (string, int, error) {
|
||||
s = strings.TrimRight(s, "/")
|
||||
h, p, err := net.SplitHostPort(s)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
if h == "" {
|
||||
return "", 0, fmt.Errorf("missing host")
|
||||
}
|
||||
port, err := strconv.Atoi(p)
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("bad port: %w", err)
|
||||
}
|
||||
if port <= 0 || port > 65535 {
|
||||
return "", 0, fmt.Errorf("port out of range: %d", port)
|
||||
}
|
||||
return h, port, nil
|
||||
}
|
||||
|
||||
type vmessJSON struct {
|
||||
V interface{} `json:"v"`
|
||||
Ps string `json:"ps"`
|
||||
Add string `json:"add"`
|
||||
Port interface{} `json:"port"`
|
||||
ID string `json:"id"`
|
||||
Aid interface{} `json:"aid"`
|
||||
Scy string `json:"scy"`
|
||||
Net string `json:"net"`
|
||||
Type string `json:"type"`
|
||||
Host string `json:"host"`
|
||||
Path string `json:"path"`
|
||||
TLS string `json:"tls"`
|
||||
SNI string `json:"sni"`
|
||||
Alpn string `json:"alpn"`
|
||||
Fp string `json:"fp"`
|
||||
}
|
||||
|
||||
func parseVMess(raw string) (*Upstream, error) {
|
||||
if !strings.HasPrefix(raw, "vmess://") {
|
||||
return nil, fmt.Errorf("vmess: invalid scheme")
|
||||
}
|
||||
encoded := strings.TrimPrefix(raw, "vmess://")
|
||||
|
||||
decoded, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
decoded, err = base64.RawStdEncoding.DecodeString(encoded)
|
||||
}
|
||||
if err != nil {
|
||||
decoded, err = base64.URLEncoding.DecodeString(encoded)
|
||||
}
|
||||
if err != nil {
|
||||
decoded, err = base64.RawURLEncoding.DecodeString(encoded)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("vmess: base64 decode: %w", err)
|
||||
}
|
||||
|
||||
var v vmessJSON
|
||||
if err := json.Unmarshal(decoded, &v); err != nil {
|
||||
return nil, fmt.Errorf("vmess: json decode: %w", err)
|
||||
}
|
||||
|
||||
port := 0
|
||||
switch p := v.Port.(type) {
|
||||
case float64:
|
||||
port = int(p)
|
||||
case string:
|
||||
port, _ = strconv.Atoi(p)
|
||||
case int:
|
||||
port = p
|
||||
}
|
||||
|
||||
if v.Add == "" || port == 0 || v.ID == "" {
|
||||
return nil, fmt.Errorf("vmess: missing required fields")
|
||||
}
|
||||
|
||||
alterID := 0
|
||||
switch a := v.Aid.(type) {
|
||||
case float64:
|
||||
alterID = int(a)
|
||||
case string:
|
||||
alterID, _ = strconv.Atoi(a)
|
||||
case int:
|
||||
alterID = a
|
||||
}
|
||||
|
||||
security := v.Scy
|
||||
if security == "" {
|
||||
security = "auto"
|
||||
}
|
||||
|
||||
transport := v.Net
|
||||
if transport == "" {
|
||||
transport = "tcp"
|
||||
}
|
||||
|
||||
sni := v.SNI
|
||||
if sni == "" {
|
||||
sni = v.Host
|
||||
}
|
||||
if sni == "" {
|
||||
sni = v.Add
|
||||
}
|
||||
|
||||
return &Upstream{
|
||||
Scheme: SchemeVMess,
|
||||
Host: v.Add,
|
||||
Port: port,
|
||||
Tag: v.Ps,
|
||||
Raw: raw,
|
||||
Transport: transport,
|
||||
Path: v.Path,
|
||||
ServiceName: "",
|
||||
VMess: &VMessConfig{
|
||||
UUID: v.ID,
|
||||
AlterID: alterID,
|
||||
Security: security,
|
||||
TLS: v.TLS == "tls",
|
||||
ServerName: sni,
|
||||
SkipCertVerify: true,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package upstream
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type Scheme string
|
||||
|
||||
const (
|
||||
SchemeShadowsocks Scheme = "ss"
|
||||
SchemeVLESS Scheme = "vless"
|
||||
SchemeTrojan Scheme = "trojan"
|
||||
SchemeVMess Scheme = "vmess"
|
||||
)
|
||||
|
||||
type Upstream struct {
|
||||
Scheme Scheme
|
||||
Host string
|
||||
Port int
|
||||
Tag string
|
||||
Raw string
|
||||
|
||||
// Protocol-specific
|
||||
SS *ShadowsocksConfig
|
||||
VLESS *VLESSConfig
|
||||
Trojan *TrojanConfig
|
||||
VMess *VMessConfig
|
||||
|
||||
// Transport-specific (common for VLESS/Trojan)
|
||||
Transport string // tcp, ws, grpc, xhttp
|
||||
Path string
|
||||
Header map[string]string
|
||||
ServiceName string
|
||||
}
|
||||
|
||||
func (u *Upstream) Address() string {
|
||||
return net.JoinHostPort(u.Host, strconv.Itoa(u.Port))
|
||||
}
|
||||
|
||||
type ShadowsocksConfig struct {
|
||||
Method string
|
||||
Password string
|
||||
}
|
||||
|
||||
type VLESSConfig struct {
|
||||
UUID string
|
||||
Security string // none, tls, reality
|
||||
Encryption string
|
||||
Flow string
|
||||
SNI string
|
||||
Fingerprint string
|
||||
PublicKey string
|
||||
ShortID string
|
||||
SpiderX string
|
||||
}
|
||||
|
||||
type TrojanConfig struct {
|
||||
Password string
|
||||
Security string // none, tls
|
||||
SNI string
|
||||
}
|
||||
|
||||
type VMessConfig struct {
|
||||
UUID string
|
||||
AlterID int
|
||||
Security string // auto, aes-128-gcm, chacha20-poly1305, none
|
||||
TLS bool
|
||||
ServerName string
|
||||
SkipCertVerify bool
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
// Package worker runs the checker's control loop and the per-session check
|
||||
// pipeline. Cycles run one at a time in the poll loop, which is the global lock:
|
||||
// a scheduled full cycle and a manual "recheck selected" can never overlap.
|
||||
package worker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"git.qomar.pw/omar/zhguchiy_perchik/internal/checker"
|
||||
"git.qomar.pw/omar/zhguchiy_perchik/internal/config"
|
||||
"git.qomar.pw/omar/zhguchiy_perchik/internal/db"
|
||||
"git.qomar.pw/omar/zhguchiy_perchik/internal/fetcher"
|
||||
"git.qomar.pw/omar/zhguchiy_perchik/internal/geoip"
|
||||
"git.qomar.pw/omar/zhguchiy_perchik/internal/notify"
|
||||
"git.qomar.pw/omar/zhguchiy_perchik/internal/upstream"
|
||||
)
|
||||
|
||||
// Manual-trigger settings keys written by the API and consumed by the loop.
|
||||
const (
|
||||
keyManualRun = "manual_run_requested"
|
||||
keyManualRecheck = "manual_recheck_ids"
|
||||
)
|
||||
|
||||
// Worker owns the check pipeline dependencies.
|
||||
type Worker struct {
|
||||
db *db.DB
|
||||
chk *checker.Checker
|
||||
geo *geoip.Resolver
|
||||
tg *notify.Telegram
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// New builds a Worker.
|
||||
func New(database *db.DB, chk *checker.Checker, geo *geoip.Resolver, log *slog.Logger) *Worker {
|
||||
return &Worker{db: database, chk: chk, geo: geo, tg: notify.New(), log: log}
|
||||
}
|
||||
|
||||
// Run drives the control loop until ctx is cancelled. It polls every 5s for
|
||||
// manual triggers and the auto-interval schedule.
|
||||
func (w *Worker) Run(ctx context.Context) {
|
||||
var lastFull time.Time
|
||||
if recent, err := w.db.RecentSessions(ctx, 1); err == nil && len(recent) > 0 {
|
||||
lastFull = recent[0].StartedAt
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
w.log.Info("worker started")
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
w.log.Info("worker stopping")
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
|
||||
settings := w.loadSettings(ctx)
|
||||
|
||||
// Manual "recheck selected" has priority.
|
||||
if ids := w.takeRecheckIDs(ctx); len(ids) > 0 {
|
||||
w.runRecheckSelected(ctx, settings, ids)
|
||||
continue
|
||||
}
|
||||
// Manual full cycle.
|
||||
if w.takeManualRun(ctx) {
|
||||
w.runFullCycle(ctx, settings, "manual")
|
||||
lastFull = time.Now()
|
||||
continue
|
||||
}
|
||||
// Scheduled full cycle.
|
||||
if settings.AutoEnabled {
|
||||
interval := time.Duration(settings.CheckIntervalHours * float64(time.Hour))
|
||||
if lastFull.IsZero() || time.Since(lastFull) >= interval {
|
||||
w.runFullCycle(ctx, settings, "scheduled")
|
||||
lastFull = time.Now()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Worker) loadSettings(ctx context.Context) config.Settings {
|
||||
m, err := w.db.GetSettings(ctx)
|
||||
if err != nil {
|
||||
w.log.Warn("settings load failed, using defaults", "err", err)
|
||||
return config.Default()
|
||||
}
|
||||
return config.FromMap(m)
|
||||
}
|
||||
|
||||
func (w *Worker) takeManualRun(ctx context.Context) bool {
|
||||
v, ok, err := w.db.GetSetting(ctx, keyManualRun)
|
||||
if err != nil || !ok || v != "true" {
|
||||
return false
|
||||
}
|
||||
_ = w.db.SetSetting(ctx, keyManualRun, "false")
|
||||
return true
|
||||
}
|
||||
|
||||
func (w *Worker) takeRecheckIDs(ctx context.Context) []int64 {
|
||||
v, ok, err := w.db.GetSetting(ctx, keyManualRecheck)
|
||||
if err != nil || !ok || strings.TrimSpace(v) == "" {
|
||||
return nil
|
||||
}
|
||||
_ = w.db.SetSetting(ctx, keyManualRecheck, "")
|
||||
var ids []int64
|
||||
for _, part := range strings.Split(v, ",") {
|
||||
if n, err := strconv.ParseInt(strings.TrimSpace(part), 10, 64); err == nil {
|
||||
ids = append(ids, n)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func checkerConfig(s config.Settings) checker.Config {
|
||||
return checker.Config{
|
||||
MaxLatencyMs: s.MaxLatencyMs,
|
||||
MinSpeedMbps: s.MinSpeedMbps,
|
||||
SpeedTestEnabled: s.SpeedTestEnabled,
|
||||
SpeedTestURL: s.SpeedTestURL,
|
||||
DialTimeout: s.Timeout(),
|
||||
SpeedTestTimeout: 30 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// candidate is a deduplicated proxy to check in a full cycle.
|
||||
type candidate struct {
|
||||
canonical string
|
||||
protocol string
|
||||
host string
|
||||
port int
|
||||
sourceID int64
|
||||
sourceName string
|
||||
}
|
||||
|
||||
// runFullCycle fetches sources, canonicalizes/dedups, checks every candidate,
|
||||
// and (on success) makes the session current.
|
||||
func (w *Worker) runFullCycle(ctx context.Context, s config.Settings, trigger string) {
|
||||
start := time.Now()
|
||||
sessionID, err := w.db.CreateSession(ctx, trigger)
|
||||
if err != nil {
|
||||
w.log.Error("create session failed", "err", err)
|
||||
return
|
||||
}
|
||||
cycleCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
cancelled := w.watchCancel(cycleCtx, sessionID, cancel)
|
||||
|
||||
w.notify(ctx, s, s.TelegramNotifyStart, fmt.Sprintf("▶️ Проверка запущена (сессия #%d, %s)", sessionID, trigger))
|
||||
w.log.Info("full cycle start", "session", sessionID, "trigger", trigger)
|
||||
|
||||
candidates, totalRaw := w.collect(cycleCtx, sessionID)
|
||||
unique := len(candidates)
|
||||
collapsed := totalRaw - unique
|
||||
if collapsed < 0 {
|
||||
collapsed = 0
|
||||
}
|
||||
_ = w.db.SetSessionTotals(cycleCtx, sessionID, totalRaw, unique, collapsed, unique)
|
||||
w.log.Info("candidates collected", "session", sessionID, "raw", totalRaw, "unique", unique)
|
||||
|
||||
prevSet, _ := w.db.CurrentSessionProxyIDs(cycleCtx)
|
||||
cfg := checkerConfig(s)
|
||||
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
sem = make(chan struct{}, s.Workers)
|
||||
mu sync.Mutex
|
||||
passed int
|
||||
failed int
|
||||
done int
|
||||
)
|
||||
for _, c := range candidates {
|
||||
if cycleCtx.Err() != nil {
|
||||
break
|
||||
}
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func(c candidate) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
|
||||
w.checkOne(cycleCtx, sessionID, c, cfg, prevSet, &mu, &passed, &failed)
|
||||
|
||||
mu.Lock()
|
||||
done++
|
||||
if done%25 == 0 {
|
||||
_ = w.db.UpdateSessionProgress(cycleCtx, sessionID, done, passed, failed)
|
||||
}
|
||||
mu.Unlock()
|
||||
}(c)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// Final writes use the parent ctx: the cycle ctx may already be cancelled.
|
||||
_ = w.db.UpdateSessionProgress(ctx, sessionID, passed+failed, passed, failed)
|
||||
dur := time.Since(start).Milliseconds()
|
||||
if cancelled() {
|
||||
_ = w.db.FailSession(ctx, sessionID, "cancelled", "cancelled by operator", dur)
|
||||
w.log.Info("full cycle cancelled", "session", sessionID, "passed", passed, "failed", failed)
|
||||
w.notify(ctx, s, s.TelegramNotifyFinish, fmt.Sprintf("⏹ Проверка #%d отменена", sessionID))
|
||||
return
|
||||
}
|
||||
if err := w.db.CompleteSession(ctx, sessionID, dur); err != nil {
|
||||
w.log.Error("complete session failed", "session", sessionID, "err", err)
|
||||
_ = w.db.FailSession(ctx, sessionID, "failed", err.Error(), dur)
|
||||
return
|
||||
}
|
||||
w.log.Info("full cycle done", "session", sessionID, "valid", passed, "invalid", failed, "ms", dur)
|
||||
w.notify(ctx, s, s.TelegramNotifyFinish, fmt.Sprintf(
|
||||
"✅ Проверка #%d завершена\nВалидных: %d\nНевалидных: %d\nУникальных кандидатов: %d\nВремя: %.1fs",
|
||||
sessionID, passed, failed, unique, float64(dur)/1000))
|
||||
}
|
||||
|
||||
// collect fetches every active source, canonicalizes and dedups its lines, and
|
||||
// records per-source stats. Returns the unique candidates and the raw line total.
|
||||
func (w *Worker) collect(ctx context.Context, sessionID int64) ([]candidate, int) {
|
||||
sources, err := w.db.ListActiveSources(ctx)
|
||||
if err != nil {
|
||||
w.log.Warn("list sources failed", "err", err)
|
||||
return nil, 0
|
||||
}
|
||||
seen := make(map[string]struct{})
|
||||
var candidates []candidate
|
||||
totalRaw := 0
|
||||
|
||||
for _, src := range sources {
|
||||
if ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
lines, ferr := fetcher.FetchAll(ctx, []fetcher.Source{{URL: src.URL, Name: src.Name}}, w.log)
|
||||
errStr := ""
|
||||
if ferr != nil {
|
||||
errStr = ferr.Error()
|
||||
}
|
||||
_ = w.db.UpdateSourceFetchStats(ctx, src.ID, len(lines), errStr)
|
||||
totalRaw += len(lines)
|
||||
|
||||
uniqForSrc := 0
|
||||
for _, ln := range lines {
|
||||
canon, up, cerr := upstream.Canonicalize(ln)
|
||||
if cerr != nil {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[canon]; dup {
|
||||
continue
|
||||
}
|
||||
seen[canon] = struct{}{}
|
||||
uniqForSrc++
|
||||
candidates = append(candidates, candidate{
|
||||
canonical: canon,
|
||||
protocol: string(up.Scheme),
|
||||
host: up.Host,
|
||||
port: up.Port,
|
||||
sourceID: src.ID,
|
||||
sourceName: src.Name,
|
||||
})
|
||||
}
|
||||
_ = w.db.UpsertSourceStat(ctx, sessionID, src.Name, len(lines), uniqForSrc)
|
||||
}
|
||||
return candidates, totalRaw
|
||||
}
|
||||
|
||||
// checkOne runs the pipeline for one candidate and records the outcome.
|
||||
func (w *Worker) checkOne(ctx context.Context, sessionID int64, c candidate,
|
||||
cfg checker.Config, prevSet map[int64]struct{}, mu *sync.Mutex, passed, failed *int) {
|
||||
|
||||
sid := c.sourceID
|
||||
proxyID, _, err := w.db.EnsureProxy(ctx, c.canonical, c.protocol, c.host, c.port, &sid, c.sourceName)
|
||||
if err != nil {
|
||||
w.log.Warn("ensure proxy failed", "url", c.canonical, "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
res := w.chk.Check(ctx, c.canonical, cfg)
|
||||
_ = w.db.BumpProtocolStat(ctx, sessionID, c.protocol, res.Passed)
|
||||
|
||||
if !res.Passed {
|
||||
_ = w.db.MarkProxyFail(ctx, proxyID)
|
||||
mu.Lock()
|
||||
*failed++
|
||||
mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
_ = w.db.MarkProxyPass(ctx, proxyID, res.LatencyMs, res.SpeedMbps, res.Country, res.ExitIP)
|
||||
_, isKnown := prevSet[proxyID]
|
||||
_ = w.db.AddSessionProxy(ctx, &db.SessionProxy{
|
||||
ProxyID: proxyID,
|
||||
CanonicalURL: c.canonical,
|
||||
Protocol: c.protocol,
|
||||
Host: c.host,
|
||||
Port: c.port,
|
||||
SourceName: c.sourceName,
|
||||
LatencyMs: res.LatencyMs,
|
||||
SpeedMbps: res.SpeedMbps,
|
||||
Country: res.Country,
|
||||
ExitIP: res.ExitIP,
|
||||
IsNew: !isKnown,
|
||||
}, sessionID)
|
||||
_ = w.db.BumpSourcePassed(ctx, sessionID, c.sourceName)
|
||||
mu.Lock()
|
||||
*passed++
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
// runRecheckSelected re-validates chosen proxies in place within the current
|
||||
// session: passing ones have their metrics refreshed, failing ones are dropped
|
||||
// from the current view. It does not create a new current session.
|
||||
func (w *Worker) runRecheckSelected(ctx context.Context, s config.Settings, ids []int64) {
|
||||
cur, err := w.db.GetCurrentSession(ctx)
|
||||
if err != nil || cur == nil {
|
||||
w.log.Warn("recheck: no current session")
|
||||
return
|
||||
}
|
||||
refs, err := w.db.GetProxyRefs(ctx, ids)
|
||||
if err != nil {
|
||||
w.log.Warn("recheck: load refs failed", "err", err)
|
||||
return
|
||||
}
|
||||
w.log.Info("recheck selected start", "count", len(refs), "session", cur.ID)
|
||||
cfg := checkerConfig(s)
|
||||
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
sem = make(chan struct{}, s.Workers)
|
||||
drop []int64
|
||||
mu sync.Mutex
|
||||
)
|
||||
for _, r := range refs {
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func(r db.ProxyRef) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
|
||||
res := w.chk.Check(ctx, r.CanonicalURL, cfg)
|
||||
if !res.Passed {
|
||||
_ = w.db.MarkProxyFail(ctx, r.ID)
|
||||
mu.Lock()
|
||||
drop = append(drop, r.ID)
|
||||
mu.Unlock()
|
||||
return
|
||||
}
|
||||
_ = w.db.MarkProxyPass(ctx, r.ID, res.LatencyMs, res.SpeedMbps, res.Country, res.ExitIP)
|
||||
_ = w.db.AddSessionProxy(ctx, &db.SessionProxy{
|
||||
ProxyID: r.ID,
|
||||
CanonicalURL: r.CanonicalURL,
|
||||
Protocol: r.Protocol,
|
||||
Host: r.Host,
|
||||
Port: r.Port,
|
||||
SourceName: r.SourceName,
|
||||
LatencyMs: res.LatencyMs,
|
||||
SpeedMbps: res.SpeedMbps,
|
||||
Country: res.Country,
|
||||
ExitIP: res.ExitIP,
|
||||
}, cur.ID)
|
||||
}(r)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if len(drop) > 0 {
|
||||
_ = w.db.DeleteSessionProxies(ctx, cur.ID, drop)
|
||||
}
|
||||
w.log.Info("recheck selected done", "rechecked", len(refs), "dropped", len(drop))
|
||||
}
|
||||
|
||||
// watchCancel polls the session's cancel flag every 2s and cancels the cycle
|
||||
// context when set. The returned closure reports whether cancel fired.
|
||||
func (w *Worker) watchCancel(ctx context.Context, sessionID int64, cancel context.CancelFunc) func() bool {
|
||||
var flag atomic.Bool
|
||||
go func() {
|
||||
t := time.NewTicker(2 * time.Second)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
b, err := w.db.IsCancelRequested(ctx, sessionID)
|
||||
if err == nil && b {
|
||||
flag.Store(true)
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
return flag.Load
|
||||
}
|
||||
|
||||
func (w *Worker) notify(ctx context.Context, s config.Settings, when bool, text string) {
|
||||
if !when {
|
||||
return
|
||||
}
|
||||
if err := w.tg.Send(ctx, s.TelegramBotToken, s.TelegramChatID, text); err != nil {
|
||||
w.log.Warn("telegram send failed", "err", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
-- zhguchiy_perchik schema. Applied idempotently by the checker on boot.
|
||||
-- Model: "only working / last completed session". The panel and subscriptions
|
||||
-- always read the single check_sessions row with is_current = true.
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- settings: typed key/value config editable from the panel.
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key text PRIMARY KEY,
|
||||
value text NOT NULL,
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- sources: proxy source lists (txt / subscription URLs).
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS sources (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
name text NOT NULL,
|
||||
url text NOT NULL UNIQUE,
|
||||
enabled boolean NOT NULL DEFAULT true,
|
||||
last_fetched_at timestamptz,
|
||||
last_line_count integer NOT NULL DEFAULT 0,
|
||||
last_error text NOT NULL DEFAULT '',
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- proxies: persistent per-canonical-URL history. Kept even while a proxy is
|
||||
-- currently invalid. Never served directly — only working ones from the
|
||||
-- current session are. Source is pinned first-seen.
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS proxies (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
canonical_url text NOT NULL UNIQUE,
|
||||
protocol text NOT NULL,
|
||||
host text NOT NULL,
|
||||
port integer NOT NULL,
|
||||
source_id bigint REFERENCES sources(id) ON DELETE SET NULL,
|
||||
source_name text NOT NULL DEFAULT '',
|
||||
first_seen timestamptz NOT NULL DEFAULT now(),
|
||||
last_alive timestamptz,
|
||||
consecutive_failures integer NOT NULL DEFAULT 0,
|
||||
total_checks bigint NOT NULL DEFAULT 0,
|
||||
total_passes bigint NOT NULL DEFAULT 0,
|
||||
last_latency_ms integer NOT NULL DEFAULT 0,
|
||||
last_speed_mbps double precision NOT NULL DEFAULT 0,
|
||||
last_country text NOT NULL DEFAULT '',
|
||||
last_exit_ip text NOT NULL DEFAULT '',
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_proxies_protocol ON proxies (protocol);
|
||||
CREATE INDEX IF NOT EXISTS idx_proxies_last_country ON proxies (last_country);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- check_sessions: one per check cycle. Kept forever (trends). Exactly one row
|
||||
-- has is_current = true (enforced by the partial unique index below).
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS check_sessions (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
status text NOT NULL DEFAULT 'running', -- running|completed|cancelled|failed
|
||||
trigger_kind text NOT NULL DEFAULT 'scheduled', -- scheduled|manual
|
||||
is_current boolean NOT NULL DEFAULT false,
|
||||
cancel_requested boolean NOT NULL DEFAULT false,
|
||||
started_at timestamptz NOT NULL DEFAULT now(),
|
||||
finished_at timestamptz,
|
||||
duration_ms bigint NOT NULL DEFAULT 0,
|
||||
total_raw_lines integer NOT NULL DEFAULT 0,
|
||||
unique_candidates integer NOT NULL DEFAULT 0,
|
||||
collapsed integer NOT NULL DEFAULT 0, -- raw - unique (dedup/junk count)
|
||||
progress_total integer NOT NULL DEFAULT 0,
|
||||
progress_done integer NOT NULL DEFAULT 0,
|
||||
valid integer NOT NULL DEFAULT 0,
|
||||
invalid integer NOT NULL DEFAULT 0,
|
||||
error text NOT NULL DEFAULT ''
|
||||
);
|
||||
-- exactly one current session
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uniq_current_session
|
||||
ON check_sessions (is_current) WHERE is_current;
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_status_finished
|
||||
ON check_sessions (status, finished_at DESC);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- session_proxies: working proxies of a session with metrics at that session.
|
||||
-- Denormalized so subscription/proxy-list serving is a single filtered scan.
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS session_proxies (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
session_id bigint NOT NULL REFERENCES check_sessions(id) ON DELETE CASCADE,
|
||||
proxy_id bigint NOT NULL REFERENCES proxies(id) ON DELETE CASCADE,
|
||||
canonical_url text NOT NULL,
|
||||
protocol text NOT NULL,
|
||||
host text NOT NULL,
|
||||
port integer NOT NULL,
|
||||
source_name text NOT NULL DEFAULT '',
|
||||
latency_ms integer NOT NULL DEFAULT 0,
|
||||
speed_mbps double precision NOT NULL DEFAULT 0,
|
||||
country text NOT NULL DEFAULT '',
|
||||
exit_ip text NOT NULL DEFAULT '',
|
||||
is_new boolean NOT NULL DEFAULT false,
|
||||
UNIQUE (session_id, proxy_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sp_session ON session_proxies (session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sp_session_protocol ON session_proxies (session_id, protocol);
|
||||
CREATE INDEX IF NOT EXISTS idx_sp_session_country ON session_proxies (session_id, country);
|
||||
CREATE INDEX IF NOT EXISTS idx_sp_session_exitip ON session_proxies (session_id, exit_ip);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Compact per-session aggregate stats (protocol / source breakdown incl. valid%).
|
||||
-- We do NOT store every failed candidate — only these counters.
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS session_protocol_stats (
|
||||
session_id bigint NOT NULL REFERENCES check_sessions(id) ON DELETE CASCADE,
|
||||
protocol text NOT NULL,
|
||||
checked integer NOT NULL DEFAULT 0,
|
||||
passed integer NOT NULL DEFAULT 0,
|
||||
failed integer NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (session_id, protocol)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS session_source_stats (
|
||||
session_id bigint NOT NULL REFERENCES check_sessions(id) ON DELETE CASCADE,
|
||||
source_name text NOT NULL,
|
||||
raw_lines integer NOT NULL DEFAULT 0,
|
||||
unique_candidates integer NOT NULL DEFAULT 0,
|
||||
passed integer NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (session_id, source_name)
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- subscriptions: client sub-links with filters, format, unique_ips, sort, ttl.
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS subscriptions (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
token text NOT NULL UNIQUE,
|
||||
name text NOT NULL DEFAULT '',
|
||||
enabled boolean NOT NULL DEFAULT true,
|
||||
format text NOT NULL DEFAULT 'plain', -- plain|base64|clash|singbox
|
||||
filter_protocols text[] NOT NULL DEFAULT '{}',
|
||||
filter_countries text[] NOT NULL DEFAULT '{}',
|
||||
filter_sources text[] NOT NULL DEFAULT '{}',
|
||||
max_latency_ms integer,
|
||||
min_speed_mbps double precision,
|
||||
limit_n integer,
|
||||
unique_ips boolean NOT NULL DEFAULT false,
|
||||
unique_ips_metric text NOT NULL DEFAULT 'speed', -- speed|latency
|
||||
sort_by text[] NOT NULL DEFAULT '{}', -- e.g. {speed:desc,latency:asc}
|
||||
expires_at timestamptz,
|
||||
request_count bigint NOT NULL DEFAULT 0,
|
||||
last_requested_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Default settings (only inserted when missing).
|
||||
-- ---------------------------------------------------------------------------
|
||||
INSERT INTO settings (key, value) VALUES
|
||||
('check_interval_hours', '12'),
|
||||
('workers', '10'),
|
||||
('timeout_sec', '5'),
|
||||
('max_latency_ms', '1000'),
|
||||
('min_speed_mbps', '3'),
|
||||
('speedtest_enabled', 'true'),
|
||||
('speedtest_url', 'https://speed.cloudflare.com/__down?bytes=10000000'),
|
||||
('geoip_db_url', 'https://cdn.jsdelivr.net/npm/@ip-location-db/geolite2-geo-whois-asn-country-mmdb/geolite2-geo-whois-asn-country.mmdb'),
|
||||
('auto_enabled', 'true'),
|
||||
('telegram_bot_token', ''),
|
||||
('telegram_chat_id', ''),
|
||||
('telegram_notify_start', 'false'),
|
||||
('telegram_notify_finish', 'true')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
Reference in New Issue
Block a user