DOH 草稿

一键部署高性能 DNS over HTTPS (DoH) 服务


核心特性

  • 多节点竞速:同时向多个上游 DNS 发起请求,采用最快返回的结果(默认竞速 4 个节点)。
  • 动态健康评分:根据上游节点的成功率、失败率和延迟动态调整分数,自动优先选择优质节点。
  • 内存级缓存:内置 LRU 缓存机制,缓存有效 DNS 响应(默认 300 秒,最多 5000 条),降低上游请求压力。
  • IP 限流保护:基于客户端 IP 的请求频率限制(默认 60 秒内最多 250 次),防止滥用。
  • 多策略支持:内置多种 DNS 策略组(如去广告、家庭保护、安全防护等),可通过 URL 参数一键切换。

复制以下代码,粘贴到 cloudflare worker 中并保存

const CONFIG = {
  DNS_PATH: '/dns-query',
  DEFAULT_PROFILE: 'all',
  CACHE_TTL_SECONDS: 300,
  MAX_CACHE_ENTRIES: 5000,
  MAX_THROTTLE_ENTRIES: 20000,
  MAX_DNS_MESSAGE_BYTES: 4096,
  RATE_LIMIT_WINDOW_MS: 60_000,      //  60 秒
  RATE_LIMIT_MAX_REQUESTS: 250,      // 250 次(默认最大请求数阈值)
  UPSTREAM_TIMEOUT_MS: 1800,
  RACE_COUNT: 4,
  SCORE_START: 100,
  SCORE_MIN: 0,
  SCORE_MAX: 100,
  SCORE_SUCCESS_DELTA: 1,
  SCORE_FAILURE_DELTA: 15,
  SCORE_TIMEOUT_DELTA: 10
};

const ALL_DNS_UPSTREAMS = [
  'https://cloudflare-dns.com/dns-query', 'https://1.1.1.1/dns-query',
  'https://1.0.0.1/dns-query', 'https://mozilla.cloudflare-dns.com/dns-query',
  'https://security.cloudflare-dns.com/dns-query', 'https://family.cloudflare-dns.com/dns-query',
  'https://dns64.cloudflare-dns.com/dns-query', 'https://brave.cloudflare-dns.com/dns-query',
  'https://dns.google/dns-query', 'https://8888.google/dns-query',
  'https://dns64.dns.google/dns-query', 'https://dns.quad9.net/dns-query',
  'https://dns9.quad9.net/dns-query', 'https://dns10.quad9.net/dns-query',
  'https://dns11.quad9.net/dns-query', 'https://dns12.quad9.net/dns-query',
  'https://dns.nextdns.io/dns-query', 'https://doh.opendns.com/dns-query',
  'https://doh.familyshield.opendns.com/dns-query', 'https://doh.umbrella.com/dns-query',
  'https://dns.adguard-dns.com/dns-query', 'https://unfiltered.adguard-dns.com/dns-query',
  'https://family.adguard-dns.com/dns-query', 'https://doh.mullvad.net/dns-query',
  'https://adblock.doh.mullvad.net/dns-query', 'https://base.dns.mullvad.net/dns-query',
  'https://extended.dns.mullvad.net/dns-query', 'https://all.dns.mullvad.net/dns-query',
  'https://family.dns.mullvad.net/dns-query', 'https://freedns.controld.com/p0',
  'https://freedns.controld.com/p1', 'https://freedns.controld.com/p2',
  'https://freedns.controld.com/p3', 'https://freedns.controld.com/family',
  'https://freedns.controld.com/uncensored', 'https://sky.rethinkdns.com/dns-query',
  'https://doh.cleanbrowsing.org/doh/security-filter/', 'https://doh.cleanbrowsing.org/doh/adult-filter/',
  'https://doh.cleanbrowsing.org/doh/family-filter/', 'https://zero.dns0.eu/dns-query',
  'https://kids.dns0.eu/dns-query', 'https://private.canadianshield.cira.ca/dns-query',
  'https://protected.canadianshield.cira.ca/dns-query', 'https://family.canadianshield.cira.ca/dns-query',
  'https://protective.joindns4.eu/dns-query', 'https://child.joindns4.eu/dns-query',
  'https://noads.joindns4.eu/dns-query', 'https://child-noads.joindns4.eu/dns-query',
  'https://unfiltered.joindns4.eu/dns-query', 'https://wikimedia-dns.org/dns-query',
  'https://doh.wikimedia.org/dns-query', 'https://dns.switch.ch/dns-query',
  'https://dns.digitale-gesellschaft.ch/dns-query', 'https://doh.libredns.gr/dns-query',
  'https://doh.libredns.gr/noads', 'https://odvr.nic.cz/dns-query',
  'https://doh.ffmuc.net/dns-query', 'https://doh.applied-privacy.net/query',
  'https://dns.aa.net.uk/dns-query', 'https://dns.alidns.com/dns-query',
  'https://dns.twnic.tw/dns-query', 'https://dns.pub/dns-query',
  'https://doh.360.cn/dns-query', 'https://public.dns.iij.jp/dns-query',
  'https://doh.dns.sb/dns-query', 'https://doh.pub/dns-query',
  'https://ordns.he.net/dns-query', 'https://dns.brahma.world/dns-query',
  'https://dns.cfiec.net/dns-query', 'https://dns.dnshome.de/dns-query',
  'https://dnsforge.de/dns-query', 'https://clean.dnsforge.de/dns-query',
  'https://hard.dnsforge.de/dns-query', 'https://doh-fi.blahdns.com/dns-query',
  'https://doh-jp.blahdns.com/dns-query', 'https://doh-de.blahdns.com/dns-query',
  'https://doh-sg.blahdns.com/dns-query', 'https://doh.centraleu.pi-dns.com/dns-query',
  'https://doh.westus.pi-dns.com/dns-query', 'https://doh.eastus.pi-dns.com/dns-query',
  'https://doh.northeu.pi-dns.com/dns-query', 'https://doh.tiar.app/dns-query',
  'https://doh.tiarap.org/dns-query', 'https://jp.tiar.app/dns-query',
  'https://jp.tiarap.org/dns-query', 'https://dns.containerpi.com/dns-query',
  'https://dns.rubyfish.cn/dns-query', 'https://doh.armadillodns.net/dns-query',
  'https://commons.host/dns-query', 'https://doh.crypto.sx/dns-query',
  'https://dns.dnswarden.com/uncensored', 'https://resolver-eu.lelux.fi/dns-query',
  'https://doh.bortzmeyer.fr/dns-query', 'https://dns.oszx.co/dns-query',
  'https://ada.openbld.net/dns-query', 'https://ric.openbld.net/dns-query',
  'https://luna.openbld.net/dns-query', 'https://fra01.dnscry.pt/dns-query',
  'https://lon01.dnscry.pt/dns-query', 'https://nyc01.dnscry.pt/dns-query',
  'https://par01.dnscry.pt/dns-query', 'https://ams01.dnscry.pt/dns-query',
  'https://sin01.dnscry.pt/dns-query', 'https://syd01.dnscry.pt/dns-query',
  'https://tok01.dnscry.pt/dns-query', 'https://sea01.dnscry.pt/dns-query',
  'https://lax01.dnscry.pt/dns-query', 'https://anycast.uncensoreddns.org/dns-query',
  'https://unicast.uncensoreddns.org/dns-query', 'https://dns.njal.la/dns-query',
  'https://freedom.mydns.network/dns-query', 'https://paranoia.mydns.network/dns-query',
  'https://adblock.mydns.network/dns-query', 'https://family.mydns.network/dns-query',
  'https://dns.comss.one/dns-query', 'https://router.comss.one/dns-query',
  'https://ca01.dns4me.net', 'https://ca02.dns4me.net',
  'https://us01.dns4me.net', 'https://us02.dns4me.net',
  'https://uk01.dns4me.net', 'https://au01.dns4me.net',
  'https://sg01.dns4me.net', 'https://de01.dns4me.net',
  'https://dnspub.restena.lu/dns-query', 'https://safeservedns.com/dns-query',
  'https://dns.rabbitdns.org/dns-query', 'https://security.rabbitdns.org/dns-query',
  'https://family.rabbitdns.org/dns-query', 'https://v.recipes/dns-query',
  'https://v.recipes/dns-adblock', 'https://v.recipes/dns-ecs',
  'https://dns.surfsharkdns.com/dns-query', 'https://dns.blokada.org/dns-query',
  'https://root.hagezi.org/dns-query', 'https://wurzn.hagezi.org/dns-query',
  'https://juuri.hagezi.org/dns-query', 'https://eu1.dns.lavate.ch/dns-query',
  'https://doh.seby.io/dns-query', 'https://resolver1.absolight.net/dns-query',
  'https://resolver2.absolight.net/dns-query', 'https://per.adfilter.net/dns-query',
  'https://syd.adfilter.net/dns-query', 'https://adl.adfilter.net/dns-query',
  'https://ns0.fdn.fr/dns-query', 'https://ns1.fdn.fr/dns-query',
  'https://dns.technitium.com/dns-query', 'https://dns.telekom.de/dns-query',
  'https://dns.aquilenet.fr/dns-query', 'https://doh.lacontrevoie.fr/dns-query',
  'https://dns.belnet.be/dns-query', 'https://dns1.in-berlin.de/dns-query',
  'https://dns2.in-berlin.de/dns-query', 'https://resolver.dnsprivacy.org.uk/dns-query',
  'https://resolver.sunet.se/dns-query', 'https://ns1.opennameserver.org/dns-query',
  'https://dns.froth.zone/dns-query', 'https://dns.stormycloud.org/dns-query',
  'https://adfree.usableprivacy.net/dns-query', 'https://doh.dns4all.eu/dns-query',
  'https://dns.smartguard.io/dns-query', 'https://privacy.plumedns.com/dns-query',
  'https://dns.bitdefender.net/dns-query', 'https://dns.cctld.kg/dns-query',
  'https://doh.lv/dns-query', 'https://doh.nic.lv/dns-query',
  'https://japan.dnsovertor.cc/dns-query', 'https://chuncheon.dnsovertor.cc/dns-query',
  'https://seoul.dnsovertor.cc/dns-query', 'https://dns.cert.ee/dns-query',
  'https://secure.hafnova.com/dns-query', 'https://dns.kescher.at/dns-query',
  'https://ibuki.cgnat.net/dns-query', 'https://doh.li/dns-query',
  'https://dns4eu.online/dns-query', 'https://dns.elemental.software/dns-query',
  'https://doth.huque.com/dns-query', 'https://zdn.ro/dns-query',
  'https://doh.zknt.org/dns-query', 'https://ns2.4netguides.org/dns-query',
  'https://dukun.de/dns-query', 'https://dns.cynthialabs.net/dns-query'
];

// 定义不同的 DNS 上游节点池(如去广告、家庭保护、安全防护等),通过 URL 参数一键切换
const RESOLVER_PROFILES = {
  all: ALL_DNS_UPSTREAMS,

  default: [
    'https://cloudflare-dns.com/dns-query',
    'https://1.1.1.1/dns-query',
    'https://1.0.0.1/dns-query',
    'https://mozilla.cloudflare-dns.com/dns-query',
    'https://brave.cloudflare-dns.com/dns-query',
    'https://dns.google/dns-query',
    'https://8888.google/dns-query',
    'https://dns.quad9.net/dns-query',
    'https://dns11.quad9.net/dns-query',
    'https://doh.mullvad.net/dns-query',
    'https://base.dns.mullvad.net/dns-query',
    'https://unfiltered.adguard-dns.com/dns-query',
    'https://freedns.controld.com/p0',
    'https://freedns.controld.com/uncensored',
    'https://wikimedia-dns.org/dns-query',
    'https://doh.wikimedia.org/dns-query',
    'https://dns.switch.ch/dns-query',
    'https://odvr.nic.cz/dns-query'
  ],

  security: [
    'https://security.cloudflare-dns.com/dns-query',
    'https://dns.quad9.net/dns-query',
    'https://dns9.quad9.net/dns-query',
    'https://dns10.quad9.net/dns-query',
    'https://dns11.quad9.net/dns-query',
    'https://dns12.quad9.net/dns-query',
    'https://protected.canadianshield.cira.ca/dns-query',
    'https://doh.cleanbrowsing.org/doh/security-filter/',
    'https://zero.dns0.eu/dns-query',
    'https://protective.joindns4.eu/dns-query',
    'https://safeservedns.com/dns-query',
    'https://security.rabbitdns.org/dns-query',
    'https://dns.bitdefender.net/dns-query',
    'https://dns.cert.ee/dns-query'
  ],

  family: [
    'https://family.cloudflare-dns.com/dns-query',
    'https://family.adguard-dns.com/dns-query',
    'https://doh.familyshield.opendns.com/dns-query',
    'https://freedns.controld.com/family',
    'https://doh.cleanbrowsing.org/doh/adult-filter/',
    'https://doh.cleanbrowsing.org/doh/family-filter/',
    'https://kids.dns0.eu/dns-query',
    'https://family.canadianshield.cira.ca/dns-query',
    'https://child.joindns4.eu/dns-query',
    'https://child-noads.joindns4.eu/dns-query',
    'https://family.dns.mullvad.net/dns-query',
    'https://family.mydns.network/dns-query',
    'https://family.rabbitdns.org/dns-query'
  ],

  adblock: [
    'https://dns.adguard-dns.com/dns-query',
    'https://adblock.doh.mullvad.net/dns-query',
    'https://doh.libredns.gr/noads',
    'https://noads.joindns4.eu/dns-query',
    'https://child-noads.joindns4.eu/dns-query',
    'https://adblock.mydns.network/dns-query',
    'https://dns.blokada.org/dns-query',
    'https://root.hagezi.org/dns-query',
    'https://wurzn.hagezi.org/dns-query',
    'https://juuri.hagezi.org/dns-query',
    'https://v.recipes/dns-adblock',
    'https://adfree.usableprivacy.net/dns-query'
  ],

  dns64: [
    'https://dns64.cloudflare-dns.com/dns-query',
    'https://dns64.dns.google/dns-query'
  ]
};

const APP_STATE = {
  resolversByProfile: buildResolverState(RESOLVER_PROFILES),
  cache: new Map(),
  throttle: new Map()
};

function buildResolverState(profiles) {
  const state = {};

  for (const [profile, urls] of Object.entries(profiles)) {
    state[profile] = urls.map((url) => ({
      url,
      score: CONFIG.SCORE_START,
      ok: 0,
      fail: 0,
      timeout: 0,
      lastLatencyMs: null,
      lastError: null
    }));
  }

  return state;
}

export default {
  async fetch(req, env, ctx) {
    const url = new URL(req.url);
    const clientIP = getClientIP(req);

    if (url.pathname === CONFIG.DNS_PATH) {
      if (checkSpam(clientIP)) {
        return textResponse('Rate limit exceeded', 429, {
          'cache-control': 'no-store'
        });
      }

      return handleDNS(req, url);
    }

    if (url.pathname === '/health') {
      return jsonResponse(getHealthSnapshot(), 200, {
        'cache-control': 'no-store'
      });
    }

    return textResponse('Not found', 404);
  }
};

async function handleDNS(req, url) {
  const methodError = validateMethod(req.method);
  if (methodError) return methodError;

  let payload;

  try {
    payload = await readDNSPayload(req, url);
  } catch (err) {
    return textResponse(err.message || 'Invalid DNS query', err.status || 400, {
      'cache-control': 'no-store'
    });
  }

  if (!payload || payload.byteLength === 0) {
    return textResponse('Empty DNS query', 400, { 'cache-control': 'no-store' });
  }

  if (payload.byteLength > CONFIG.MAX_DNS_MESSAGE_BYTES) {
    return textResponse('DNS message too large', 413, { 'cache-control': 'no-store' });
  }

  const parsed = parseDNSQuestion(payload);
  if (!parsed.ok) {
    return textResponse(parsed.error, 400, { 'cache-control': 'no-store' });
  }

  const profile = pickProfile(url);
  const resolvers = APP_STATE.resolversByProfile[profile] || APP_STATE.resolversByProfile[CONFIG.DEFAULT_PROFILE];
  const cacheKey = await makeCacheKey(profile, parsed.questionKey);
  const hit = getCache(cacheKey);

  if (hit) {
    const responseBody = patchDNSResponseID(hit.body, parsed.id);
    return dnsResponse(responseBody, {
      'x-cache': 'HIT',
      'x-profile': profile
    });
  }

  const racers = selectRacers(resolvers);

  try {
    const winner = await raceResolvers(racers, payload, parsed.id);

    if (isCacheableDNSResponse(winner.body)) {
      setCache(cacheKey, normalizeDNSResponseID(winner.body), CONFIG.CACHE_TTL_SECONDS);
    }

    return dnsResponse(winner.body, {
      'x-cache': 'MISS',
      'x-profile': profile,
      'x-winner': sanitizeHeaderValue(winner.url),
      'x-winner-lat': `${winner.latencyMs}ms`
    });
  } catch (err) {
    return textResponse('Global resolving failed', 502, {
      'cache-control': 'no-store',
      'x-profile': profile
    });
  }
}

function validateMethod(method) {
  if (method !== 'GET' && method !== 'POST') {
    return textResponse('Method not allowed', 405, {
      allow: 'GET, POST',
      'cache-control': 'no-store'
    });
  }

  return null;
}

async function readDNSPayload(req, url) {
  if (req.method === 'GET') {
    const q = url.searchParams.get('dns');
    if (!q) throw httpError('Missing dns query parameter', 400);
    return decodeBase64Url(q);
  }

  const contentType = req.headers.get('content-type') || '';
  if (!contentType.toLowerCase().includes('application/dns-message')) {
    throw httpError('POST requires content-type: application/dns-message', 415);
  }

  return new Uint8Array(await req.arrayBuffer());
}

function decodeBase64Url(input) {
  if (!/^[A-Za-z0-9_-]+$/.test(input)) {
    throw httpError('Invalid base64url DNS query', 400);
  }

  let normalized = input.replace(/-/g, '+').replace(/_/g, '/');
  normalized += '='.repeat((4 - (normalized.length % 4)) % 4);

  try {
    return Uint8Array.from(atob(normalized), (c) => c.charCodeAt(0));
  } catch (_) {
    throw httpError('Invalid base64url DNS query', 400);
  }
}

function parseDNSQuestion(packet) {
  const bytes = packet instanceof Uint8Array ? packet : new Uint8Array(packet);

  if (bytes.byteLength < 12) {
    return { ok: false, error: 'DNS message too short' };
  }

  const id = (bytes[0] << 8) | bytes[1];
  const flags = (bytes[2] << 8) | bytes[3];
  const qdcount = (bytes[4] << 8) | bytes[5];

  if ((flags & 0x8000) !== 0) {
    return { ok: false, error: 'DNS query expected, got response' };
  }

  if (qdcount !== 1) {
    return { ok: false, error: 'Exactly one DNS question is required' };
  }

  let offset = 12;
  const labels = [];

  while (offset < bytes.length) {
    const len = bytes[offset++];

    if (len === 0) break;

    if ((len & 0xc0) !== 0) {
      return { ok: false, error: 'Compressed question names are not accepted' };
    }

    if (len > 63 || offset + len > bytes.length) {
      return { ok: false, error: 'Invalid DNS question name' };
    }

    let label = '';
    for (let i = 0; i < len; i++) {
      const ch = bytes[offset++];
      label += String.fromCharCode(ch).toLowerCase();
    }

    labels.push(label);
  }

  if (offset + 4 > bytes.length) {
    return { ok: false, error: 'Incomplete DNS question' };
  }

  const qtype = (bytes[offset] << 8) | bytes[offset + 1];
  const qclass = (bytes[offset + 2] << 8) | bytes[offset + 3];
  const qname = labels.join('.') || '.';

  return {
    ok: true,
    id,
    qname,
    qtype,
    qclass,
    questionKey: `${qname}|${qtype}|${qclass}`
  };
}

function normalizeDNSResponseID(responseBuffer) {
  const bytes = new Uint8Array(responseBuffer);
  const copy = new Uint8Array(bytes.length);
  copy.set(bytes);
  copy[0] = 0;
  copy[1] = 0;
  return copy.buffer;
}

function patchDNSResponseID(responseBuffer, queryID) {
  const bytes = new Uint8Array(responseBuffer);
  const copy = new Uint8Array(bytes.length);
  copy.set(bytes);
  copy[0] = (queryID >> 8) & 0xff;
  copy[1] = queryID & 0xff;
  return copy.buffer;
}

function isCacheableDNSResponse(responseBuffer) {
  const bytes = new Uint8Array(responseBuffer);

  if (bytes.length < 12) return false;

  const flags = (bytes[2] << 8) | bytes[3];
  const isResponse = (flags & 0x8000) !== 0;
  const rcode = flags & 0x000f;

  if (!isResponse) return false;

  return rcode === 0 || rcode === 3;
}

// 从 URL 的 profile 参数中解析选择的策略,若无效或未提供则回退到至默认
function pickProfile(url) {
  const raw = (url.searchParams.get('profile') || CONFIG.DEFAULT_PROFILE).toLowerCase();
  return APP_STATE.resolversByProfile[raw] ? raw : CONFIG.DEFAULT_PROFILE;
}

// 根据健康分数和延迟对节点排序/竞优
function selectRacers(resolvers) {
  return [...resolvers]
    .sort((a, b) => {
      if (b.score !== a.score) return b.score - a.score;
      const aLat = a.lastLatencyMs ?? Number.MAX_SAFE_INTEGER;
      const bLat = b.lastLatencyMs ?? Number.MAX_SAFE_INTEGER;
      return aLat - bLat;
    })
    .slice(0, Math.max(1, Math.min(CONFIG.RACE_COUNT, resolvers.length)));
}

// 并发请求,利用 Promise.any 采用最快返回的结果并取消其他请求
async function raceResolvers(nodes, packet, expectedID) {
  const controllers = nodes.map(() => new AbortController());

  try {
    const attempts = nodes.map((node, index) => relay(node, packet, expectedID, controllers[index].signal));
    const winner = await Promise.any(attempts);

    for (const controller of controllers) {
      controller.abort('winner-selected');
    }

    return winner;
  } finally {
    for (const controller of controllers) {
      controller.abort('race-finished');
    }
  }
}

async function relay(node, packet, expectedID, signal) {
  const started = Date.now();
  const timeoutController = new AbortController();
  const timeout = setTimeout(() => timeoutController.abort('timeout'), CONFIG.UPSTREAM_TIMEOUT_MS);
  const combinedSignal = anySignal([signal, timeoutController.signal]);

  try {
    const res = await fetch(node.url, {
      method: 'POST',
      headers: {
        accept: 'application/dns-message',
        'content-type': 'application/dns-message'
      },
      body: packet,
      signal: combinedSignal
    });

    if (!res.ok) {
      penalize(node, CONFIG.SCORE_FAILURE_DELTA, `HTTP ${res.status}`);
      throw new Error(`Upstream HTTP ${res.status}`);
    }

    const body = await res.arrayBuffer();
    const validation = validateDNSResponse(body, expectedID);

    if (!validation.ok) {
      penalize(node, CONFIG.SCORE_FAILURE_DELTA, validation.error);
      throw new Error(validation.error);
    }

    const latencyMs = Date.now() - started;
    reward(node, latencyMs);

    return {
      url: node.url,
      body,
      latencyMs
    };
  } catch (err) {
    const message = String(err && err.message ? err.message : err);

    if (message.includes('abort') || message.includes('timeout') || timeoutController.signal.aborted) {
      node.timeout += 1;
      penalize(node, CONFIG.SCORE_TIMEOUT_DELTA, 'timeout');
    } else {
      penalize(node, CONFIG.SCORE_FAILURE_DELTA, message);
    }

    throw err;
  } finally {
    clearTimeout(timeout);
  }
}

function validateDNSResponse(responseBuffer, expectedID) {
  const bytes = new Uint8Array(responseBuffer);

  if (bytes.length < 12) return { ok: false, error: 'Upstream returned short DNS response' };

  const id = (bytes[0] << 8) | bytes[1];
  const flags = (bytes[2] << 8) | bytes[3];

  if (id !== expectedID) return { ok: false, error: 'Upstream response ID mismatch' };
  if ((flags & 0x8000) === 0) return { ok: false, error: 'Upstream returned a DNS query, not response' };

  return { ok: true };
}

function anySignal(signals) {
  const controller = new AbortController();

  function abortFrom(signal) {
    if (!controller.signal.aborted) {
      controller.abort(signal.reason || 'aborted');
    }
  }

  for (const signal of signals) {
    if (!signal) continue;
    if (signal.aborted) {
      abortFrom(signal);
      break;
    }
    signal.addEventListener('abort', () => abortFrom(signal), { once: true });
  }

  return controller.signal;
}

// 请求成功则增加健康分数并记录最新延迟
function reward(node, latencyMs) {
  node.ok += 1;
  node.lastLatencyMs = latencyMs;
  node.lastError = null;
  node.score = clamp(node.score + CONFIG.SCORE_SUCCESS_DELTA, CONFIG.SCORE_MIN, CONFIG.SCORE_MAX);
}

// 请求失败或超时则扣除健康分数并记录错误信息
function penalize(node, amount, error) {
  node.fail += 1;
  node.lastError = String(error || 'unknown').slice(0, 80);
  node.score = clamp(node.score - amount, CONFIG.SCORE_MIN, CONFIG.SCORE_MAX);
}

async function makeCacheKey(profile, questionKey) {
  const input = `${profile}|${questionKey}`;
  const bytes = new TextEncoder().encode(input);
  const digest = await crypto.subtle.digest('SHA-256', bytes);
  return [...new Uint8Array(digest)].map((x) => x.toString(16).padStart(2, '0')).join('');
}

// 若命中缓存则刷新其 Map 顺序,并检查是否已过期
function getCache(key) {
  const item = APP_STATE.cache.get(key);
  if (!item) return null;

  if (Date.now() > item.expiresAt) {
    APP_STATE.cache.delete(key);
    return null;
  }

  APP_STATE.cache.delete(key);
  APP_STATE.cache.set(key, item);
  return item;
}

// 写入缓存并设置过期时间,若超出最大条目限制则自动清理最旧的缓存
function setCache(key, body, ttlSeconds) {
  APP_STATE.cache.set(key, {
    body,
    expiresAt: Date.now() + ttlSeconds * 1000
  });

  trimMap(APP_STATE.cache, CONFIG.MAX_CACHE_ENTRIES);
}

// 基于 IP 统计请求频率,若超出阈值(顶部)则视为滥用并拦截
function checkSpam(ip) {
  const now = Date.now();
  const current = APP_STATE.throttle.get(ip);

// 初始化或重置计数器:窗口大小为 CONFIG.RATE_LIMIT_WINDOW_MS (60秒)
  let stats = current || { count: 0, resetAt: now + CONFIG.RATE_LIMIT_WINDOW_MS };

  if (now > stats.resetAt) {
    stats = { count: 0, resetAt: now + CONFIG.RATE_LIMIT_WINDOW_MS };
  }

// 每次请求计数 +1
  stats.count += 1;
  APP_STATE.throttle.set(ip, stats);

  trimMap(APP_STATE.throttle, CONFIG.MAX_THROTTLE_ENTRIES);

// 判断是否超过阈值
  return stats.count > CONFIG.RATE_LIMIT_MAX_REQUESTS;
}

function trimMap(map, maxEntries) {
  while (map.size > maxEntries) {
    const oldestKey = map.keys().next().value;
    map.delete(oldestKey);
  }
}

function getClientIP(req) {
  return req.headers.get('CF-Connecting-IP')
    || req.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
    || 'unknown';
}

function getHealthSnapshot() {
  const profiles = {};

  for (const [profile, nodes] of Object.entries(APP_STATE.resolversByProfile)) {
    profiles[profile] = nodes.map((node) => ({
      url: node.url,
      score: node.score,
      ok: node.ok,
      fail: node.fail,
      timeout: node.timeout,
      lastLatencyMs: node.lastLatencyMs,
      lastError: node.lastError
    }));
  }

  return {
    cacheEntries: APP_STATE.cache.size,
    throttleEntries: APP_STATE.throttle.size,
    profiles
  };
}

function dnsResponse(body, extraHeaders = {}) {
  return new Response(body, {
    status: 200,
    headers: {
      'content-type': 'application/dns-message',
      'cache-control': 'no-store',
      ...extraHeaders
    }
  });
}

function textResponse(text, status = 200, headers = {}) {
  return new Response(text, {
    status,
    headers: {
      'content-type': 'text/plain; charset=utf-8',
      ...headers
    }
  });
}

function jsonResponse(data, status = 200, headers = {}) {
  return new Response(JSON.stringify(data, null, 2), {
    status,
    headers: {
      'content-type': 'application/json; charset=utf-8',
      ...headers
    }
  });
}

function httpError(message, status) {
  const err = new Error(message);
  err.status = status;
  return err;
}

function clamp(value, min, max) {
  return Math.max(min, Math.min(max, value));
}

function sanitizeHeaderValue(value) {
  return String(value).replace(/[\r\n]/g, '').slice(0, 200);
}
This article was updated on

Related post

567567

如果你告诉我:你看到的是“Applications/Authorized OAuth apps”哪个页面、以及你授权的名称(不需要提供隐私信息,只要应用名列表),我可以按你的界面给你更精确的点击路径。…