unique_ips collapsed proxies per exit address, so one operator holding many adjacent addresses counted as many distinct nodes. On the live pool the NL exit layer looked like 87 unique IPs but was 40 distinct /24s, with 33 of them in a single block — roughly three of four circuits leaving through one of two operators. Add a scope alongside the existing metric, so the two axes are independent: unique_ips_scope = ip | subnet (ip = previous behaviour, default) unique_ips_metric = speed | latency (unchanged) unique_subnet_v4 = prefix bits, default 24 unique_subnet_v6 = prefix bits, default 48 The collapse key generalises from the exit address to a masked netip.Prefix. Prefix lengths are clamped (v4 8-32, v6 16-128) rather than rejected so a stored subscription can never render an empty payload, and the API persists the normalized value so the panel shows what is actually served. Exits that are empty or unparseable still pass through uncollapsed — dropping them would discard distinct nodes. IPv4-mapped IPv6 is unmapped before masking. Schema upgrade is idempotent and defaults reproduce the old behaviour, so existing subscriptions keep serving the same node set until switched over. The same scope is exposed on the Proxies tab (and its export) so the effect can be previewed before it is applied to a subscription. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
6.6 KiB
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) markedis_current = trueis what everything reads. When a new session completes, a single transaction flipsis_currentto the new one (WHERE is_currentpartial-unique index guarantees exactly one). session_proxiesholds 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.proxiesis 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
proxiesrow. - 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)
- Successful protocol handshake + exit IP obtained within timeout.
- Latency ≤ threshold (setting).
- Speed ≥ threshold (setting) — speedtest is a validity gate, run for every candidate that passes connect+latency (when speedtest enabled).
Uniqueness
Canonical URL (import-time, internal/upstream/canonical.go):
unescape &→& (and ;/double amp; variants) → drop #fragment →
per-protocol query whitelist → sort query alphabetically → normalize form
(scheme case, trailing /, percent-encoding; vmess re-packed as canonical JSON)
→ dedup by canonical URL. Broken/unparseable lines rejected.
unique_ips (display/serve-time, opt-in): collapse proxies sharing one exit 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 → collapse → sort → limit top-N.
Two scopes decide what "the same exit" means:
unique_ips_scope = ip(default, legacy behaviour) — one winner per exit address.unique_ips_scope = subnet— one winner per network prefix (unique_subnet_v4, default 24;unique_subnet_v6, default 48). This is what de-duplicates a single operator holding many adjacent addresses: an exit pool that looks like 87 distinct IPs can be 40 distinct /24s. Prefix lengths are clamped (v4 8–32, v6 16–128) rather than rejected, so a stored subscription can never render an empty payload.
Exits that are empty or unparseable as an IP pass through uncollapsed — dropping them would silently discard distinct nodes. IPv4-mapped IPv6 literals are unmapped first, so they mask with the IPv4 prefix length.
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/meGET /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), optionalunique_ips+metric+scope(+subnet_v4/subnet_v6); paginationGET /proxies/export.txt— filtered list,\n-joinedPOST /proxies/recheck— recheck selected (ids) — enqueues manual opDELETE /proxies— delete selected (removes from current session view)GET/POST/PATCH/DELETE /subscriptions— CRUDGET/POST/PATCH/DELETE /sources— CRUD (+ enable/disable)GET/PATCH /settingsPOST /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.