From 5091a7b75f331612154f0ecc33f3a8ec2f66ecfe Mon Sep 17 00:00:00 2001 From: Rene Fichtmueller Date: Sat, 4 Apr 2026 08:15:32 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20proxy=20network=20=E2=80=94=20geo-looku?= =?UTF-8?q?p,=20uptime=20tracking,=20dedup=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - IP geo-lookup via ip-api.com on register/heartbeat (country_code, city) - heartbeat_count column + uptime_pct computation on every heartbeat - Deduplication: register returns existing token for same IP+port - Heartbeat no longer overwrites registered IP (prevents IPv6 churn conflicts) - Migration 023: heartbeat_count column + backfill existing nodes --- packages/api/src/routes/proxy.ts | 113 +++++++++++++++++++++++++--- sql/023-proxy-node-improvements.sql | 21 ++++++ 2 files changed, 123 insertions(+), 11 deletions(-) create mode 100644 sql/023-proxy-node-improvements.sql diff --git a/packages/api/src/routes/proxy.ts b/packages/api/src/routes/proxy.ts index 49a5d09..0263bee 100644 --- a/packages/api/src/routes/proxy.ts +++ b/packages/api/src/routes/proxy.ts @@ -13,8 +13,45 @@ */ import { Router, Request, Response } from "express"; import * as crypto from "crypto"; +import * as http from "http"; import { pool } from "../db/client"; +/** Lookup country+city for an IP via ip-api.com (free, no key required) */ +async function geoLookup(ip: string): Promise<{ country: string | null; city: string | null }> { + // Skip private/loopback IPs + if (!ip || ip === "::1" || ip.startsWith("127.") || ip.startsWith("10.") || + ip.startsWith("192.168.") || ip.startsWith("172.")) { + return { country: null, city: null }; + } + + return new Promise((resolve) => { + const timeout = setTimeout(() => resolve({ country: null, city: null }), 3000); + http.get(`http://ip-api.com/json/${ip}?fields=countryCode,city,status`, (res) => { + let data = ""; + res.on("data", (chunk) => { data += chunk; }); + res.on("end", () => { + clearTimeout(timeout); + try { + const parsed = JSON.parse(data) as { status: string; countryCode?: string; city?: string }; + if (parsed.status === "success") { + resolve({ + country: parsed.countryCode ?? null, + city: parsed.city ?? null, + }); + } else { + resolve({ country: null, city: null }); + } + } catch { + resolve({ country: null, city: null }); + } + }); + }).on("error", () => { + clearTimeout(timeout); + resolve({ country: null, city: null }); + }); + }); +} + export const proxyRouter = Router(); const ADMIN_TOKEN = process.env.TIP_ADMIN_TOKEN ?? "tip-admin-2026"; @@ -54,21 +91,53 @@ proxyRouter.post("/register", async (req: Request, res: Response) => { req.socket.remoteAddress ?? ""; + const nodePort = port ?? 1080; + + // Deduplication: if no token provided, check for existing node at same IP+port + if (!existingToken && ip) { + const existing = await pool.query( + `SELECT id, token FROM proxy_nodes WHERE ip = $1 AND port = $2 LIMIT 1`, + [ip, nodePort] + ); + if (existing.rows.length > 0) { + const node = existing.rows[0]; + // Update metadata and mark online + await pool.query( + `UPDATE proxy_nodes SET name = COALESCE($2, name), version = COALESCE($3, version), + status = 'online', last_seen = NOW() WHERE id = $1`, + [node.id, name ?? null, version ?? null] + ); + res.json({ + success: true, + token: node.token, + nodeId: node.id, + message: "Existing node found for this IP+port — token returned.", + }); + return; + } + } + + // Geo-lookup (async, non-blocking for registration flow) + const geo = await geoLookup(ip); + // If token provided, upsert — otherwise create new const token = existingToken ?? generateToken(); const result = await pool.query( - `INSERT INTO proxy_nodes (token, name, owner_email, ip, port, version, status, last_seen) - VALUES ($1, $2, $3, $4, $5, $6, 'online', NOW()) + `INSERT INTO proxy_nodes (token, name, owner_email, ip, port, version, status, last_seen, country_code, city) + VALUES ($1, $2, $3, $4, $5, $6, 'online', NOW(), $7, $8) ON CONFLICT (token) DO UPDATE SET name = COALESCE(EXCLUDED.name, proxy_nodes.name), ip = EXCLUDED.ip, port = COALESCE(EXCLUDED.port, proxy_nodes.port), version = COALESCE(EXCLUDED.version, proxy_nodes.version), + country_code = COALESCE(EXCLUDED.country_code, proxy_nodes.country_code), + city = COALESCE(EXCLUDED.city, proxy_nodes.city), status = 'online', last_seen = NOW() RETURNING id, token, name, status`, - [token, name ?? null, owner_email ?? null, ip, port ?? 1080, version ?? null] + [token, name ?? null, owner_email ?? null, ip, nodePort, version ?? null, + geo.country, geo.city] ); const node = result.rows[0]; @@ -118,28 +187,50 @@ proxyRouter.post("/heartbeat", async (req: Request, res: Response) => { const bytesGb = (bytesProxied ?? 0) / (1024 ** 3); + // Geo-lookup only if country_code not yet set for this node + const geoCheckResult = await pool.query( + `SELECT country_code, ip FROM proxy_nodes WHERE token = $1`, + [token] + ); + let geo: { country: string | null; city: string | null } = { country: null, city: null }; + if (geoCheckResult.rows.length > 0 && !geoCheckResult.rows[0].country_code) { + // Use the stored IP (registered IP is more reliable than heartbeat source IP) + const lookupIp = geoCheckResult.rows[0].ip || remoteIp; + if (lookupIp) geo = await geoLookup(lookupIp); + } + const result = await pool.query( `UPDATE proxy_nodes SET status = 'online', last_seen = NOW(), - ip = COALESCE($2, ip), - port = COALESCE($3, port), - bytes_proxied = COALESCE($4, bytes_proxied), - requests_proxied = COALESCE($5, requests_proxied), - latency_ms = COALESCE($6, latency_ms), - version = COALESCE($7, version), - bandwidth_used_gb = COALESCE($8, bandwidth_used_gb) + port = COALESCE($2, port), + bytes_proxied = COALESCE($3, bytes_proxied), + requests_proxied = COALESCE($4, requests_proxied), + latency_ms = COALESCE($5, latency_ms), + version = COALESCE($6, version), + bandwidth_used_gb = COALESCE($7, bandwidth_used_gb), + country_code = COALESCE($8, country_code), + city = COALESCE($9, city), + heartbeat_count = heartbeat_count + 1, + uptime_pct = LEAST(99.9, + ROUND( + ((heartbeat_count + 1) * 30.0 / + GREATEST(1, EXTRACT(EPOCH FROM (NOW() - registered_at)))) * 100, + 2 + ) + ) WHERE token = $1 RETURNING id, status`, [ token, - remoteIp || null, port ?? null, bytesProxied ?? null, requestsProxied ?? null, latencyMs ?? null, version ?? null, bytesGb > 0 ? bytesGb : null, + geo.country, + geo.city, ] ); diff --git a/sql/023-proxy-node-improvements.sql b/sql/023-proxy-node-improvements.sql new file mode 100644 index 0000000..850fad1 --- /dev/null +++ b/sql/023-proxy-node-improvements.sql @@ -0,0 +1,21 @@ +-- Migration 023: Proxy node improvements +-- Adds heartbeat_count for uptime computation +-- Adds unique index on (ip, port) for deduplication + +-- Track actual heartbeats received so we can compute uptime_pct accurately +ALTER TABLE proxy_nodes + ADD COLUMN IF NOT EXISTS heartbeat_count INTEGER NOT NULL DEFAULT 0; + +-- Unique index on ip+port so we can deduplicate registrations from same machine +-- NOTE: partial index — only enforces uniqueness when ip IS NOT NULL +CREATE UNIQUE INDEX IF NOT EXISTS idx_proxy_nodes_ip_port + ON proxy_nodes (ip, port) + WHERE ip IS NOT NULL; + +-- Backfill: estimate heartbeat_count from registered_at + 30s interval +-- Assumes ~95% uptime for existing nodes that are currently online +UPDATE proxy_nodes +SET heartbeat_count = GREATEST(1, + FLOOR(EXTRACT(EPOCH FROM (NOW() - registered_at)) / 30)::INTEGER + ) +WHERE status = 'online' AND heartbeat_count = 0;