Files
omarandClaude Opus 5 5785be4964 Subscriptions: uniqueness by subnet, not just by exit IP
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>
2026-08-08 17:48:55 +03:00

6.6 KiB
Raw Permalink Blame History

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 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)

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 + scope (+ subnet_v4/subnet_v6); 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.