最近在 L 站上看到好几个佬友因为翻墙被叔叔请去喝茶了,感觉还是比较慌,毕竟年底了,大家都在刷 KPI。为了尽可能地降低风险,简单学习了一下可能泄露个人信息的环节,打算从 DNS 泄露这里入手。

因为人在墙内,所有流量肯定都会经过运营商,遭到审查是在所难免的。在进行 DNS 查询的时候,如果被发现查询了 google 等被墙的站点,那毫无疑问就会判定存在翻墙行为。为了避免 DNS 查询泄露,一种简单方式就是启用 DoH (DNS over Https),即将 DNS 查询过程封装在 Https 流量中,避免中间人对 DNS 数据进行窃听。

DoH 服务器有很多,但最稳妥的方式还是自建一个,利用 Cloudflare 的 worker 即可几乎零成本部署。

在 Cloudflare 中新建一个 worker,复制以下代码到 worker 中:

let dohServer = "cloudflare-dns.com";
let dohPath = 'dns-query';

export default {
  async fetch(request, env) {
    const jsonDoH = `https://${dohServer}/resolve`;
    const dnsDoH = `https://${dohServer}/dns-query`;
    const url = new URL(request.url);
    const path = url.pathname;

    // 处理OPTIONS预检请求
    if (request.method === 'OPTIONS') {
      return new Response(null, {
        headers: {
          'Access-Control-Allow-Origin': '*',
          'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
          'Access-Control-Allow-Headers': '*',
          'Access-Control-Max-Age': '86400'
        }
      });
    }

    // DoH服务器处理
    if (path === `/${dohPath}`) {
      return await handleDoHRequest(request, dnsDoH, jsonDoH);
    }

    // DNS查询参数处理
    if (url.searchParams.has("doh")) {
      return await handleDnsQuery(url, dnsDoH);
    }

    // 默认返回nginx欢迎页
    return new Response(getNginxPage(), {
      headers: {
        'Content-Type': 'text/html; charset=UTF-8',
      },
    });
  }
}

// 处理DoH请求
async function handleDoHRequest(request, dnsDoH, jsonDoH) {
  const { method, headers, body } = request;
  const userAgent = headers.get('User-Agent') || 'DoH Client';
  const url = new URL(request.url);
  const { searchParams } = url;

  try {
    if (method === 'GET' && !url.search) {
      return new Response('Bad Request', {
        status: 400,
        headers: {
          'Content-Type': 'text/plain; charset=utf-8',
          'Access-Control-Allow-Origin': '*'
        }
      });
    }

    let response;

    if (method === 'GET' && searchParams.has('name')) {
      const searchQuery = searchParams.has('type') ? url.search : url.search + '&type=A';
      response = await fetch(dnsDoH + searchQuery, {
        headers: {
          'Accept': 'application/dns-json',
          'User-Agent': userAgent
        }
      });

      if (!response.ok) {
        response = await fetch(jsonDoH + searchQuery, {
          headers: {
            'Accept': 'application/dns-json',
            'User-Agent': userAgent
          }
        });
      }
    } else if (method === 'GET') {
      response = await fetch(dnsDoH + url.search, {
        headers: {
          'Accept': 'application/dns-message',
          'User-Agent': userAgent
        }
      });
    } else if (method === 'POST') {
      response = await fetch(dnsDoH, {
        method: 'POST',
        headers: {
          'Accept': 'application/dns-message',
          'Content-Type': 'application/dns-message',
          'User-Agent': userAgent
        },
        body: body
      });
    } else {
      return new Response('Unsupported request format', {
        status: 400,
        headers: {
          'Content-Type': 'text/plain; charset=utf-8',
          'Access-Control-Allow-Origin': '*'
        }
      });
    }

    if (!response.ok) {
      const errorText = await response.text();
      throw new Error(`DoH returned error (${response.status}): ${errorText.substring(0, 200)}`);
    }

    const responseHeaders = new Headers(response.headers);
    responseHeaders.set('Access-Control-Allow-Origin', '*');
    responseHeaders.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
    responseHeaders.set('Access-Control-Allow-Headers', '*');

    if (method === 'GET' && searchParams.has('name')) {
      responseHeaders.set('Content-Type', 'application/json');
    }

    return new Response(response.body, {
      status: response.status,
      statusText: response.statusText,
      headers: responseHeaders
    });
  } catch (error) {
    console.error("DoH request error:", error);
    return new Response(JSON.stringify({
      error: `DoH request error: ${error.message}`,
      stack: error.stack
    }, null, 4), {
      status: 500,
      headers: {
        'Content-Type': 'application/json',
        'Access-Control-Allow-Origin': '*'
      }
    });
  }
}

// 处理DNS查询请求
async function handleDnsQuery(url, dnsDoH) {
  const domain = url.searchParams.get("domain") || url.searchParams.get("name") || "www.google.com";
  const dohUrl = url.searchParams.get("doh") || dnsDoH;
  const type = url.searchParams.get("type") || "all";

  if (dohUrl.includes(url.host)) {
    return await handleLocalDohRequest(domain, type, dnsDoH);
  }

  try {
    if (type === "all") {
      const ipv4Result = await queryDns(dohUrl, domain, "A");
      const ipv6Result = await queryDns(dohUrl, domain, "AAAA");
      const nsResult = await queryDns(dohUrl, domain, "NS");

      const combinedResult = {
        Status: ipv4Result.Status || ipv6Result.Status || nsResult.Status,
        TC: ipv4Result.TC || ipv6Result.TC || nsResult.TC,
        RD: ipv4Result.RD || ipv6Result.RD || nsResult.RD,
        RA: ipv4Result.RA || ipv6Result.RA || nsResult.RA,
        AD: ipv4Result.AD || ipv6Result.AD || nsResult.AD,
        CD: ipv4Result.CD || ipv6Result.CD || nsResult.CD,
        Question: [],
        Answer: [...(ipv4Result.Answer || []), ...(ipv6Result.Answer || [])],
        ipv4: {
          records: ipv4Result.Answer || []
        },
        ipv6: {
          records: ipv6Result.Answer || []
        },
        ns: {
          records: []
        }
      };

      [ipv4Result, ipv6Result, nsResult].forEach(result => {
        if (result.Question) {
          if (Array.isArray(result.Question)) {
            combinedResult.Question.push(...result.Question);
          } else {
            combinedResult.Question.push(result.Question);
          }
        }
      });

      const nsRecords = [];
      if (nsResult.Answer && nsResult.Answer.length > 0) {
        nsResult.Answer.forEach(record => {
          if (record.type === 2) {
            nsRecords.push(record);
          }
        });
      }

      if (nsResult.Authority && nsResult.Authority.length > 0) {
        nsResult.Authority.forEach(record => {
          if (record.type === 2 || record.type === 6) {
            nsRecords.push(record);
            combinedResult.Answer.push(record);
          }
        });
      }

      combinedResult.ns.records = nsRecords;

      return new Response(JSON.stringify(combinedResult, null, 2), {
        headers: { "content-type": "application/json; charset=UTF-8" }
      });
    } else {
      const result = await queryDns(dohUrl, domain, type);
      return new Response(JSON.stringify(result, null, 2), {
        headers: { "content-type": "application/json; charset=UTF-8" }
      });
    }
  } catch (err) {
    console.error("DNS query failed:", err);
    return new Response(JSON.stringify({
      error: `DNS query failed: ${err.message}`,
      doh: dohUrl,
      domain: domain,
      stack: err.stack
    }, null, 2), {
      headers: { "content-type": "application/json; charset=UTF-8" },
      status: 500
    });
  }
}

// DNS查询通用函数
async function queryDns(dohServer, domain, type) {
  const dohUrl = new URL(dohServer);
  dohUrl.searchParams.set("name", domain);
  dohUrl.searchParams.set("type", type);

  const fetchOptions = [
    { headers: { 'Accept': 'application/dns-json' } },
    { headers: {} },
    { headers: { 'Accept': 'application/json' } },
    { headers: { 'Accept': 'application/dns-json', 'User-Agent': 'Mozilla/5.0 DNS Client' } }
  ];

  let lastError = null;

  for (const options of fetchOptions) {
    try {
      const response = await fetch(dohUrl.toString(), options);

      if (response.ok) {
        const contentType = response.headers.get('content-type') || '';
        if (contentType.includes('json') || contentType.includes('dns-json')) {
          return await response.json();
        } else {
          const textResponse = await response.text();
          try {
            return JSON.parse(textResponse);
          } catch (jsonError) {
            throw new Error(`Cannot parse response as JSON: ${jsonError.message}`);
          }
        }
      }

      const errorText = await response.text();
      lastError = new Error(`DoH server error (${response.status}): ${errorText.substring(0, 200)}`);

    } catch (err) {
      lastError = err;
    }
  }

  throw lastError || new Error("DNS query failed");
}

// 处理本地DoH请求
async function handleLocalDohRequest(domain, type, dnsDoH) {
  try {
    if (type === "all") {
      const [ipv4Result, ipv6Result, nsResult] = await Promise.all([
        queryDns(dnsDoH, domain, "A"),
        queryDns(dnsDoH, domain, "AAAA"),
        queryDns(dnsDoH, domain, "NS")
      ]);

      const nsRecords = [];

      if (nsResult.Answer && nsResult.Answer.length > 0) {
        nsRecords.push(...nsResult.Answer.filter(record => record.type === 2));
      }

      if (nsResult.Authority && nsResult.Authority.length > 0) {
        nsRecords.push(...nsResult.Authority.filter(record => record.type === 2 || record.type === 6));
      }

      const combinedResult = {
        Status: ipv4Result.Status || ipv6Result.Status || nsResult.Status,
        TC: ipv4Result.TC || ipv6Result.TC || nsResult.TC,
        RD: ipv4Result.RD || ipv6Result.RD || nsResult.RD,
        RA: ipv4Result.RA || ipv6Result.RA || nsResult.RA,
        AD: ipv4Result.AD || ipv6Result.AD || nsResult.AD,
        CD: ipv4Result.CD || ipv6Result.CD || nsResult.CD,
        Question: [...(ipv4Result.Question || []), ...(ipv6Result.Question || []), ...(nsResult.Question || [])],
        Answer: [
          ...(ipv4Result.Answer || []),
          ...(ipv6Result.Answer || []),
          ...nsRecords
        ],
        ipv4: {
          records: ipv4Result.Answer || []
        },
        ipv6: {
          records: ipv6Result.Answer || []
        },
        ns: {
          records: nsRecords
        }
      };

      return new Response(JSON.stringify(combinedResult, null, 2), {
        headers: {
          "content-type": "application/json; charset=UTF-8",
          'Access-Control-Allow-Origin': '*'
        }
      });
    } else {
      const result = await queryDns(dnsDoH, domain, type);
      return new Response(JSON.stringify(result, null, 2), {
        headers: {
          "content-type": "application/json; charset=UTF-8",
          'Access-Control-Allow-Origin': '*'
        }
      });
    }
  } catch (err) {
    console.error("DoH query failed:", err);
    return new Response(JSON.stringify({
      error: `DoH query failed: ${err.message}`,
      stack: err.stack
    }, null, 2), {
      headers: {
        "content-type": "application/json; charset=UTF-8",
        'Access-Control-Allow-Origin': '*'
      },
      status: 500
    });
  }
}

// 返回nginx默认欢迎页
function getNginxPage() {
  return `<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
<style>
    body {
        width: 35em;
        margin: 0 auto;
        font-family: Tahoma, Verdana, Arial, sans-serif;
    }
</style>
</head>
<body>
<h1>Welcome to nginx!</h1>
<p>If you see this page, the nginx web server is successfully installed and
working. Further configuration is required.</p>

<p>For online documentation and support please refer to
<a href="http://nginx.org/">nginx.org</a>.<br/>
Commercial support is available at
<a href="http://nginx.com/">nginx.com</a>.</p>

<p><em>Thank you for using nginx.</em></p>
</body>
</html>`;
}

部署之后,将 worker 的地址填写到 Chrome 的自定义 DNS 服务器中,并启用安全 DNS:

然后在 https://ipleak.net/ 中验证 DoH 是否生效。

启用前

只要有墙内的 DNS 服务器出现,就证明 DNS 查询数据泄露了,难逃审查……

启用后

只有自部署的 DNS 服务器出现在列表中,极大地降低了被审查的风险~