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>
The country matrix stayed sorted by proxy-total even in unique_ips mode. The
client now sorts by the active metric (total vs unique exit IPs) and the header
reads "IPs" when unique. Backend returns all countries (≤40) so the re-rank is
correct, not just a reorder of the top-15-by-proxy.
(The dashboard already reflects the last completed session — is_current flips
only on successful completion; the running session is separate.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TtR4PP2JM9KadaBkhPGAE
Replaces the flat Top-locations bar list with a country × protocol matrix: each
country row shows total plus, for vless/vmess/trojan/ss, the count (in the
protocol's colour) and average ping. The unique_ips toggle switches counts to
distinct exit IPs. Backend adds GeoBreakdown (count/unique/avg-latency per
country+protocol) assembled into a geo[] payload.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TtR4PP2JM9KadaBkhPGAE
Adds a countries_unique breakdown (count(DISTINCT exit_ip) per country) to the
dashboard payload and a unique_ips toggle on the Top locations card that switches
between raw proxy count and unique-exit-IP count. No metric choice — plain
distinct exit IP.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TtR4PP2JM9KadaBkhPGAE
On real data ~half the pool was the same server fractured into "different"
proxies by client-side/default params. The whitelist kept them all
(encryption=none, fp, packetEncoding, type=tcp/raw, headerType=none,
allowInsecure/insecure, security=none, spx, …). Since we re-check the canonical
URL, stripping these is safe — anything that mattered would fail the check.
New canonicalQuery keeps only endpoint+security+transport identity:
- normalize: security=none→∅, type=tcp/raw→∅, headerType=none→∅
- drop client hints: fp, packetEncoding, allowInsecure/insecure, spx, encryption
- scope: path/host only for ws-family, serviceName/mode only grpc/xhttp,
sni/alpn/pbk/sid only under tls/reality; alpn sorted
- default drops: sni==host under plain TLS, ws Host==sni, path="/"
- trojan defaults to TLS (sni kept without explicit security)
- vmess: drop aid=0, scy=auto, headerType=none, fp, v; same default drops
Verified on the live 2014-proxy export: 2014 → 1024 distinct (49% were dupes),
0 rejects, with a KeepsDistinct test guarding against over-merge (different
pbk/sni/path/uuid/port stay separate).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TtR4PP2JM9KadaBkhPGAE
Root cause (found by probing 15 live proxies): speed.cloudflare.com/__down
returns HTTP 429 (rate-limited) through shared proxy exit IPs, and its 1-byte
429 body was measured as "0 Mbps" with no error — so the speed gate then
rejected otherwise-fast proxies. Plain-HTTP CDN mirrors (cachefly etc.) download
reliably through the same proxies at 40-80 Mbps.
Fix:
- Default speed-test URL is now http://cachefly.cachefly.net/10mb.test.
- The checker tries the configured URL then hardcoded fallbacks (cachefly, tele2,
thinkbroadband, cloudflare). A host-side failure (dial error, HTTP 429/non-200,
empty body) moves to the next URL; a completed download — even a slow one — is
taken as the proxy's real speed (the proxy is the bottleneck).
- Only a 200 response counts; a 429/non-200 is a host failure, not a 0-Mbps proxy.
Verified end-to-end: a speedtest-ON cycle now yields valid proxies that all carry
a real speed (session had 7 valid, 41-73 Mbps) instead of everything reading 0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TtR4PP2JM9KadaBkhPGAE
The switch knob was bg-ink-950 (near-black) on a dark bg-ink-600 track — a dark
circle on a dark pill, almost invisible, and the absolute positioning rendered
inconsistently. Rewrote it as the standard inline-flex switch: lighter bordered
track, a light knob when off (on grey) and a dark knob when on (on the aqua
track), so state is clearly readable in both positions.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TtR4PP2JM9KadaBkhPGAE
The reused protocol dialers do blocking socket I/O during the handshake without
honoring the context, so a proxy that accepts TCP but stalls the handshake hung
the worker permanently — the cycle froze mid-way (observed stuck at 3300/4862,
0% CPU). Now the dial runs under a hard watchdog: it is abandoned once the dial
timeout elapses, and on success the connection gets an absolute deadline so a
stalled read/write on the tunnel can't hang the worker either.
Also mark any 'running' session as failed on worker startup — a fresh worker
can't resume a previous process's in-flight cycle (it would otherwise linger as
'running' forever).
Verified: a full ~4860-candidate cycle now advances steadily past the old freeze
point and completes (session #4, 88 valid).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TtR4PP2JM9KadaBkhPGAE
The bare `api` / `checker` ignore patterns matched the internal/api and cmd
directories, so handlers_sessions.go and handlers_ws.go were never committed —
CI's `go vet` failed on undefined handlers. Anchored the patterns to the repo
root (/api, /checker) and added the missing files.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TtR4PP2JM9KadaBkhPGAE
1. Settings toggles were wrapped in <label>, which forwarded a second click to
the switch button and cancelled the toggle — unwrapped so they respond.
2. Sessions: new GET /sessions, /sessions/{id} (per-protocol/source/country
stats), /sessions/{id}/proxies. New Sessions tab: history list + per-session
breakdown + the proxies that were valid in each session.
3. Subscriptions: url_search text[] — match canonical_url against ANY of several
substrings (OR ILIKE). Multi-value tag input in the form.
4. Live: /api/v1/ws/status websocket pushes checker status ~1s; a client hook
keeps the UI live and refreshes data queries on session completion. nginx
upgrades the connection; statusWriter now implements http.Hijacker.
5. Dashboard + Sessions source breakdown: added valid-% (passed/unique) column.
Verified via Docker + Playwright: WS 101 upgrade and 1s stream, toggle flips,
sessions detail with real data, url_search=[vless://] yields only vless configs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TtR4PP2JM9KadaBkhPGAE
- sources: new `kind` (auto|plain|base64) — fetcher honors it (plain = no
base64 decode; base64 = force decode; auto = try base64 then plain). Schema
ALTER for existing DBs, API validation, Settings UI selector (add-form + inline).
- CI: rewrite images job to the proven pattern (buildx + metadata-action +
build-push-action) using a write:package PAT secret (REGISTRY_TOKEN); the
automatic GITEA_TOKEN cannot push packages. Matrix over checker/api/web.
- compose: docker-compose.yml now pulls git.qomar.pw/omar/zhguchiy_perchik/*
images from the Gitea registry; docker-compose.build.yml is the local-build
override.
Verified on a real run: 3 aggregated lists → 7838 raw → 4842 unique (canonical
dedup) → 194 working proxies across all four protocols, served through the panel
and subscriptions.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TtR4PP2JM9KadaBkhPGAE