Initial implementation: proxy checker panel + subscription system
CI / validate (push) Successful in 41s
CI / images (push) Failing after 5s

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:
omar
2026-07-02 01:02:00 +03:00
co-authored by Claude Opus 4.8
commit 5f4271b747
95 changed files with 14391 additions and 0 deletions
+21
View File
@@ -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
+67
View File
@@ -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
View File
@@ -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
View File
@@ -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 `&amp;`→`&` (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.
+70
View File
@@ -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.
+212
View File
@@ -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;` вместо `&` → параметры
превращаются в битые `amp;type`, `amp;security` (`amp;type` ×15251 у vless), встречается
и вариант с ведущим `;` (`;type`) и двойной `amp;;security`.
- Битые строки: два конфига слиты без переноса (`…type=tcptrojan://…`), недекодируемые
base64-блобы, текст ошибок вида `订阅内容解析错误` («ошибка парсинга подписки»).
**Правила чистки (желания):**
- [x] Разэкранировать `&amp;` → `&` (и разобрать варианты с `;`/двойным `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
View File
@@ -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
}
}
+109
View File
@@ -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
}
}
+13
View File
@@ -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"]
+12
View File
@@ -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"]
+13
View File
@@ -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
+41
View File
@@ -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;
}
}
+61
View File
@@ -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:
+19
View File
@@ -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>
+3142
View File
File diff suppressed because it is too large Load Diff
+32
View File
@@ -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"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+53
View File
@@ -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>
);
}
+117
View File
@@ -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>
);
}
+47
View File
@@ -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>
);
}
+109
View File
@@ -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>
);
}
+222
View File
@@ -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>
);
}
+62
View File
@@ -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;
}
}
+90
View File
@@ -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"),
};
+72
View File
@@ -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",
};
+141
View File
@@ -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[];
}
+25
View File
@@ -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>,
);
+304
View File
@@ -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>
);
}
+85
View File
@@ -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>
);
}
+282
View File
@@ -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>
);
}
+270
View File
@@ -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>
);
}
+399
View File
@@ -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>
);
}
+58
View File
@@ -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: [],
};
+23
View File
@@ -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"]
}
+20
View File
@@ -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 },
},
},
});
+26
View File
@@ -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
)
+46
View File
@@ -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=
+101
View File
@@ -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})
}
+43
View File
@@ -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"})
}
+82
View File
@@ -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)
}
+161
View File
@@ -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)
}
+63
View File
@@ -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"})
}
+88
View File
@@ -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"})
}
+68
View File
@@ -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))
}
+133
View File
@@ -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)
}
+98
View File
@@ -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
}
+117
View File
@@ -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)
}
+222
View File
@@ -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] + "…"
}
+129
View File
@@ -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
}
+121
View File
@@ -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)
}
+90
View File
@@ -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
}
+76
View File
@@ -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()
}
+171
View File
@@ -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;
+345
View File
@@ -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
}
+77
View File
@@ -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
}
+259
View File
@@ -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()
}
+89
View File
@@ -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
}
+53
View File
@@ -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)
}
}
+59
View File
@@ -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
}
+170
View File
@@ -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
}
+62
View File
@@ -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]
}
+34
View File
@@ -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)
}
+75
View File
@@ -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
})
}
}
+72
View File
@@ -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
}
+208
View File
@@ -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"},
},
}
}
+79
View File
@@ -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
}
+98
View File
@@ -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
}
+256
View File
@@ -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
}
+148
View File
@@ -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
}
+164
View File
@@ -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")
}
+128
View File
@@ -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
}
+439
View File
@@ -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
}
+110
View File
@@ -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
}
+101
View File
@@ -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()
}
+23
View File
@@ -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
}
+43
View File
@@ -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
)
+135
View File
@@ -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
}
+76
View File
@@ -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
}
+20
View File
@@ -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")
)
+76
View File
@@ -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
}
}
}
+127
View File
@@ -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
}
+146
View File
@@ -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()
}
+51
View File
@@ -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
}
+256
View File
@@ -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" }
+155
View File
@@ -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
}
}
+318
View File
@@ -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
}
}
+80
View File
@@ -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)
}
}
+59
View File
@@ -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
}
+310
View File
@@ -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 &amp; (repeat for &amp;amp;) and the variant where the leading
// & was lost entirely (bare "amp;").
for strings.Contains(raw, "&amp;") {
raw = strings.ReplaceAll(raw, "&amp;", "&")
}
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)
}
}
+82
View File
@@ -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&amp;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))
}
+308
View File
@@ -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
}
+71
View File
@@ -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
}
+405
View File
@@ -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)
}
}
+171
View File
@@ -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;