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);
}