// Admin API client + adapters for the static prototype. (function () { const TOKEN_KEY = "chijie.admin.token"; const TOKEN_EXPIRES_KEY = "chijie.admin.token_expires_at"; class ApiError extends Error { constructor(message, status, data, authRequired = false) { super(message); this.name = "ApiError"; this.status = status; this.data = data; this.authRequired = authRequired; } } const auth = { token: localStorage.getItem(TOKEN_KEY) || "", expiresAt: Number(localStorage.getItem(TOKEN_EXPIRES_KEY) || 0), }; function setToken(token, expiresIn) { auth.token = token || ""; auth.expiresAt = token && expiresIn ? Date.now() + expiresIn * 1000 : 0; if (auth.token) { localStorage.setItem(TOKEN_KEY, auth.token); localStorage.setItem(TOKEN_EXPIRES_KEY, String(auth.expiresAt)); } else { localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(TOKEN_EXPIRES_KEY); } } function tokenMeta() { if (!auth.token) return null; const seconds = Math.max(0, Math.round((auth.expiresAt - Date.now()) / 1000)); return { token: auth.token, expiresAt: auth.expiresAt, seconds }; } function needsLogin(status, message, options) { if (options.noAuth) return false; const text = String(message || "").toLowerCase(); return status === 401 || (status === 403 && ( text.includes("missing authorization") || text.includes("invalid authorization") || text.includes("unauthorized") || text.includes("token") )); } async function request(path, options = {}) { const headers = new Headers(options.headers || {}); if (!headers.has("Accept")) headers.set("Accept", "application/json"); if (options.body != null && !headers.has("Content-Type")) { headers.set("Content-Type", "application/json"); } if (!options.noAuth && auth.token) { headers.set("Authorization", `Bearer ${auth.token}`); } let response; try { response = await fetch(path, { ...options, headers, body: typeof options.body === "string" || options.body == null ? options.body : JSON.stringify(options.body), }); } catch (err) { throw new ApiError(`request failed: ${err.message}`, 0, null); } const text = await response.text(); let data = null; if (text) { try { data = JSON.parse(text); } catch { data = { message: text }; } } if (!response.ok) { const rawMessage = data?.error || data?.message || `${response.status} ${response.statusText}`; const authRequired = needsLogin(response.status, rawMessage, options); if (authRequired) setToken("", 0); const message = authRequired ? "Please sign in again to continue." : rawMessage; throw new ApiError(message, response.status, data, authRequired); } return data; } async function login(password) { const data = await request("/api/auth/login", { method: "POST", noAuth: true, body: { password }, }); setToken(data.token, data.expires_in); return tokenMeta(); } function logout() { setToken("", 0); } function boolDefault(value, fallback = true) { return value == null ? fallback : value !== false; } function parseDurationMS(value) { if (typeof value === "number") return Math.round(value); if (!value) return 0; const raw = String(value).trim(); const match = raw.match(/^([\d.]+)\s*(ns|us|µs|ms|s|m|h)?$/); if (!match) return Number.parseInt(raw, 10) || 0; const amount = Number(match[1]); const unit = match[2] || "ms"; if (unit === "ns") return Math.round(amount / 1000000); if (unit === "us" || unit === "µs") return Math.round(amount / 1000); if (unit === "s") return Math.round(amount * 1000); if (unit === "m") return Math.round(amount * 60000); if (unit === "h") return Math.round(amount * 3600000); return Math.round(amount); } function maskSecret(value) { if (!value) return ""; const text = String(value); if (text.length <= 4) return "••••"; return `${text.slice(0, 2)}••••${text.slice(-2)}`; } function normalizePool(raw) { const cfg = raw.config || {}; const source = raw.source || cfg.source || ""; const pool = { ...cfg, name: raw.name, source, config: cfg, enabled: boolDefault(cfg.enabled, true), residential: !!cfg.residential, url: cfg.url || "", update_interval: cfg.update_interval || "", last_updated: "runtime", last_error: raw.error || "", type: cfg.type || "", template_type: cfg.template_type || "proxy", server: cfg.server || "", port: cfg.port || 0, endpoint: cfg.endpoint || "", bearer_token: maskSecret(cfg.bearer_token), username_template: cfg.username_template || "", password: maskSecret(cfg.password), priority: Number(cfg.priority || 0), coverage: cfg.coverage || (cfg.template_type === "chijie" ? "both" : (cfg.residential ? "residential" : "normal")), tags: cfg.tags || [], reject_regex: cfg.reject_regex || [], region_groups: raw.region_groups || [], }; pool.nodes = (raw.nodes || []).map(node => normalizeNode(raw, pool, node)); return pool; } function normalizeNode(rawPool, pool, node) { const name = node.name || ""; return { ...node, id: `${rawPool.name}:${name}`, name, pool: rawPool.name, poolSource: pool.source, enabled: node.enabled !== false, alive: node.alive !== false, latency: parseDurationMS(node.latency), fail_count: node.fail_count || 0, region: node.region || (node.region_group || "").replace(/-RES$/, "") || "", residential: !!node.residential, tags: node.tags || [], }; } function normalizeFingerprint(fp) { const custom = fp.type !== "preset"; const config = fp.config || {}; return { ...fp, config, custom, preset: fp.preset || config.preset || (custom ? "" : fp.name), ja3: fp.ja3 || config.ja3 || "", ja4: fp.ja4 || config.ja4 || "", akamai: fp.akamai || config.akamai || "", httpVersion: fp.http_version || config.http_version || "", method: fp.method || config.method || "", userAgent: fp.user_agent || config.user_agent || "", tested: true, lastTest: "runtime", }; } function normalizeSeries(series, metrics) { const items = (series || []).slice(-60).map((bucket, index) => { const total = Number(bucket.requests || 0); const errors = Number(bucket.failures || 0); return { t: index, total, success: Math.max(0, total - errors), errors, p95: Number(metrics?.p95_latency_ms || 0), }; }); if (items.length) return items; return Array.from({ length: 60 }, (_, t) => ({ t, total: 0, success: 0, errors: 0, p95: 0, })); } function normalizeTrace(trace) { const group = trace.egress_group || (trace.residential ? `${trace.region || "UN"}-RES` : trace.region || "DIRECT"); const url = trace.url || trace.target || ""; return { id: trace.id, ts: Date.parse(trace.timestamp) || Date.now(), type: trace.kind === "tunnel" ? "tunnel" : "http", method: trace.method || (trace.kind === "tunnel" ? "WS" : "GET"), url, region: trace.region || group.replace(/-RES$/, ""), group, strategy: trace.strategy || "random", residential: !!trace.residential, pool: trace.egress_pool || "direct", node: trace.egress_node || trace.target || "direct", template: !!trace.egress_template, tls: trace.tls_fingerprint || "", status: Number(trace.status || 0), duration_ms: Number(trace.latency_ms || 0), bytes: Number(trace.request_bytes || 0) + Number(trace.response_bytes || 0), error: trace.error || "", }; } function normalizeTraffic(snapshot) { const metrics = snapshot?.metrics || {}; return { metrics, requests: (snapshot?.traces || []).map(normalizeTrace), series: normalizeSeries(snapshot?.series || [], metrics), }; } async function loadState(options = {}) { const trafficLimit = Number(options.trafficLimit || 200); const [nodes, fingerprints, traffic, stats] = await Promise.all([ request("/api/nodes"), request("/api/fingerprints"), request(`/api/traffic?limit=${trafficLimit}`), request("/api/stats"), ]); const pools = (nodes.pools || []).map(normalizePool); return { pools, fingerprints: (fingerprints.fingerprints || []).map(normalizeFingerprint), traffic: normalizeTraffic(traffic), stats, regionGroups: window.PG.buildRegionGroups(pools), auth: tokenMeta(), }; } function cloneConfig(pool) { return { ...(pool.config || {}), source: pool.source, }; } function cleanNode(node) { const out = { name: String(node.name || "").trim(), type: String(node.type || "").trim(), server: String(node.server || "").trim(), port: Number(node.port || 0), region: String(node.region || "").trim().toUpperCase(), residential: !!node.residential, tags: node.tags || [], }; if (node.username) out.username = node.username; if (node.password) out.password = node.password; if (node.extra && Object.keys(node.extra).length) out.extra = node.extra; out.enabled = node.enabled !== false; return out; } async function updatePool(name, config, newName = "") { return request("/api/nodes/pool", { method: "PUT", body: { name, new_name: newName, config }, }); } const api = { ApiError, getTokenMeta: tokenMeta, login, logout, createProxyToken: ({ name, duration }) => request("/api/auth/proxy-token", { method: "POST", body: { name, duration }, }), loadState, refreshNodes: () => request("/api/nodes"), refreshSubscription: (pool) => request(`/api/nodes/refresh?pool=${encodeURIComponent(pool)}`, { method: "POST" }), reload: () => request("/api/reload", { method: "POST" }), updateLogging: ({ level }) => request("/api/system/logging", { method: "PUT", body: { level }, }), exportConfig: () => request("/api/config/export"), setNodeEnabled: ({ pool, node, enabled }) => request("/api/nodes/enabled", { method: "POST", body: { pool, node, enabled }, }), testNode: ({ pool, node }) => request("/api/nodes/test", { method: "POST", body: { pool, node }, }), testTemplate: ({ pool, region, url }) => request("/api/nodes/template/test", { method: "POST", body: { pool, region, url }, }), deleteNode: ({ pool, node }) => request(`/api/nodes/node?pool=${encodeURIComponent(pool)}&node=${encodeURIComponent(node)}`, { method: "DELETE", }), setPoolEnabled: (pool, enabled) => { const config = cloneConfig(pool); config.enabled = enabled; return updatePool(pool.name, config); }, updatePoolConfig: (pool, patch, newName = "") => { const config = { ...cloneConfig(pool), ...patch }; return updatePool(pool.name, config, newName); }, deletePool: (poolName) => request(`/api/nodes/pool?name=${encodeURIComponent(poolName)}`, { method: "DELETE" }), addStaticNode: (pool, node) => { const config = cloneConfig(pool); config.source = "static"; config.nodes = [...(config.nodes || []), cleanNode(node)]; return updatePool(pool.name, config); }, addPool: (name, config) => request("/api/nodes", { method: "POST", body: { name, config }, }), updateSubscriptionNode: (payload) => request("/api/nodes/subscription/node", { method: "PUT", body: payload, }), updateStaticNode: ({ pool, node, updatedNode }) => request("/api/nodes/node", { method: "PUT", body: { pool, node, updated_node: cleanNode(updatedNode) }, }), getTraffic: (limit = 200) => request(`/api/traffic?limit=${limit}`).then(normalizeTraffic), addFingerprint: ({ name, config, configText }) => request("/api/fingerprints", { method: "POST", body: { name, config, config_text: configText }, }), deleteFingerprint: (name) => request(`/api/fingerprints/${encodeURIComponent(name)}`, { method: "DELETE" }), testFingerprint: ({ fingerprint, url }) => request("/api/fingerprints/test", { method: "POST", body: { fingerprint, url }, }), }; window.PG_API = api; })();