/** * Proxy Network API routes * * POST /api/proxy/register — register new node, returns token * POST /api/proxy/heartbeat — update node status + stats * GET /api/proxy/nodes — list all nodes (admin) * GET /api/proxy/next — get best available proxy for scraper use * POST /api/proxy/rotate — mark node as used, get next * GET /api/proxy/stats — network-wide stats * * Note: register + heartbeat are public (no auth required). * nodes + rotate require admin token via X-Admin-Token header. */ 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"; function requireAdmin(req: Request, res: Response): boolean { const token = req.headers["x-admin-token"] ?? req.query["admin_token"] ?? ""; if (token !== ADMIN_TOKEN) { res.status(401).json({ error: "Admin token required" }); return false; } return true; } function generateToken(): string { return crypto.randomBytes(32).toString("hex"); } // Round-robin index tracker (in-memory, resets on restart) let rrIndex = 0; // POST /api/proxy/register proxyRouter.post("/register", async (req: Request, res: Response) => { try { const { token: existingToken, name, owner_email, port, version } = req.body as { token?: string; name?: string; owner_email?: string; port?: number; version?: string; }; const ip = (req.headers["x-forwarded-for"] as string | undefined)?.split(",")[0]?.trim() ?? 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, 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, nodePort, version ?? null, geo.country, geo.city] ); const node = result.rows[0]; res.json({ success: true, token: node.token, nodeId: node.id, message: "Node registered. Keep this token safe!", }); } catch (err) { console.error("[proxy] register error:", err); res.status(500).json({ error: "Registration failed" }); } }); // POST /api/proxy/heartbeat proxyRouter.post("/heartbeat", async (req: Request, res: Response) => { try { const { token, ip, port, bytesProxied, requestsProxied, latencyMs, version, } = req.body as { token: string; ip?: string; port?: number; bytesProxied?: number; requestsProxied?: number; latencyMs?: number; version?: string; }; if (!token) { res.status(400).json({ error: "token required" }); return; } const remoteIp = ip ?? (req.headers["x-forwarded-for"] as string | undefined)?.split(",")[0]?.trim() ?? req.socket.remoteAddress ?? ""; 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(), 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, port ?? null, bytesProxied ?? null, requestsProxied ?? null, latencyMs ?? null, version ?? null, bytesGb > 0 ? bytesGb : null, geo.country, geo.city, ] ); if (result.rowCount === 0) { res.status(404).json({ error: "Node not found. Please register first." }); return; } res.json({ success: true, status: "online" }); } catch (err) { console.error("[proxy] heartbeat error:", err); res.status(500).json({ error: "Heartbeat failed" }); } }); // GET /api/proxy/nodes (admin) proxyRouter.get("/nodes", async (req: Request, res: Response) => { if (!requireAdmin(req, res)) return; try { const result = await pool.query( `SELECT id, token, name, country_code, city, ip, port, protocol, status, last_seen, bytes_proxied, requests_proxied, uptime_pct, bandwidth_limit_gb, bandwidth_used_gb, latency_ms, version, registered_at FROM proxy_nodes ORDER BY status = 'online' DESC, last_seen DESC` ); res.json({ success: true, nodes: result.rows, total: result.rowCount }); } catch (err) { console.error("[proxy] nodes error:", err); res.status(500).json({ error: "Failed to fetch nodes" }); } }); // GET /api/proxy/next — best available proxy for scraper use proxyRouter.get("/next", async (_req: Request, res: Response) => { try { const result = await pool.query( `SELECT id, ip, port, protocol, country_code, city, latency_ms FROM proxy_nodes WHERE status = 'online' AND last_seen > NOW() - INTERVAL '2 minutes' AND (bandwidth_used_gb < bandwidth_limit_gb OR bandwidth_limit_gb = 0) ORDER BY uptime_pct DESC NULLS LAST, latency_ms ASC NULLS LAST LIMIT 10` ); if (result.rowCount === 0) { res.status(503).json({ error: "No proxies available", fallback: process.env.PROXY_URL ?? null, }); return; } const nodes = result.rows; const node = nodes[rrIndex % nodes.length]; rrIndex = (rrIndex + 1) % nodes.length; res.json({ success: true, host: node.ip, port: node.port, protocol: node.protocol ?? "socks5", nodeId: node.id, country: node.country_code, city: node.city, latencyMs: node.latency_ms, }); } catch (err) { console.error("[proxy] next error:", err); res.status(500).json({ error: "Failed to get next proxy" }); } }); // POST /api/proxy/rotate — mark node as used, get next one proxyRouter.post("/rotate", async (req: Request, res: Response) => { if (!requireAdmin(req, res)) return; try { const { nodeId } = req.body as { nodeId?: string }; if (nodeId) { // Optionally mark as temporarily degraded (just log — no ban) await pool.query( `UPDATE proxy_nodes SET requests_proxied = requests_proxied + 1 WHERE id = $1`, [nodeId] ); } // Return next proxy const result = await pool.query( `SELECT id, ip, port, protocol, country_code FROM proxy_nodes WHERE status = 'online' AND last_seen > NOW() - INTERVAL '2 minutes' AND id != COALESCE($1::uuid, '00000000-0000-0000-0000-000000000000') ORDER BY requests_proxied ASC, latency_ms ASC NULLS LAST LIMIT 1`, [nodeId ?? null] ); if (result.rowCount === 0) { res.status(503).json({ error: "No other proxies available" }); return; } const node = result.rows[0]; res.json({ success: true, host: node.ip, port: node.port, protocol: node.protocol ?? "socks5", nodeId: node.id, country: node.country_code, }); } catch (err) { console.error("[proxy] rotate error:", err); res.status(500).json({ error: "Failed to rotate proxy" }); } }); // GET /api/proxy/stats — public network stats proxyRouter.get("/stats", async (_req: Request, res: Response) => { try { const result = await pool.query(` SELECT COUNT(*) AS total_nodes, COUNT(*) FILTER (WHERE status = 'online') AS online_nodes, COUNT(*) FILTER (WHERE status = 'offline') AS offline_nodes, COUNT(DISTINCT country_code) AS countries, COALESCE(SUM(bandwidth_used_gb), 0) AS total_gb_proxied, COALESCE(SUM(requests_proxied), 0) AS total_requests, COALESCE(AVG(latency_ms) FILTER (WHERE status = 'online'), 0) AS avg_latency_ms FROM proxy_nodes `); const row = result.rows[0] ?? {}; // Per-country breakdown const countryResult = await pool.query(` SELECT country_code, COUNT(*) AS nodes, COUNT(*) FILTER (WHERE status = 'online') AS online FROM proxy_nodes WHERE country_code IS NOT NULL GROUP BY country_code ORDER BY online DESC, nodes DESC `); res.json({ success: true, network: { totalNodes: Number(row.total_nodes ?? 0), onlineNodes: Number(row.online_nodes ?? 0), offlineNodes: Number(row.offline_nodes ?? 0), countries: Number(row.countries ?? 0), totalGbProxied: parseFloat(Number(row.total_gb_proxied ?? 0).toFixed(2)), totalRequests: Number(row.total_requests ?? 0), avgLatencyMs: Math.round(Number(row.avg_latency_ms ?? 0)), }, countries: countryResult.rows, }); } catch (err) { console.error("[proxy] stats error:", err); res.status(500).json({ error: "Failed to get stats" }); } });