本文主要是介绍和分享使用AdGuard作为DOH/DOT的搭建过程以及技术分享

主要使用的设备为一台香港优化机和一台国内腾讯云, 香港作为安装放置AdGuard的设备, 腾讯云主要用来定时扫描ip可连接性不是必须的

本文用doh.com用于代替真实自部署的dns域名

AdGuard版本为 v0.107.79

在 https://jichang.mhdy.net 中有用付费套餐的可以在tg bot中得到属于自己的dot/doh

1. 系统整体拓扑

flowchart TD subgraph Client_Side [客户端终端] Browser_DoH[浏览器: 启用 DoH 内部解析器 -> 支持 Type 65 ECH] System_DoT[系统全局: 私人 DNS DoT 853端口] end Browser_DoH -->|HTTPS 443 / DoH| Nginx[OpenResty 反代 doh.com] System_DoT -->|TLS 853 / DoT| AGH_DoT[AdGuard Home 853 监听] Nginx -->|内网明文 HTTP| AGH_DoH[AdGuard Home DoH 处理引擎] subgraph HK_VPS [香港 VPS: 落地节点] Nginx AGH_DoH AGH_DoT AGH_Rules[AdGuard 过滤与重写规则库] end subgraph Domestic_VPS [国内腾讯云 VPS: 真实原生网络探针] S302_Pools[Dogfight360 CDN 离线池: Akamai/Cloudflare/Cloudfront/Fastly] Prober[generate_rules.py: 强行绕过代理 + 真实端到端 HTTPS 探测] Web[OpenResty 规则订阅源 :7865/rules.txt] S302_Pools --> Prober Prober -->|定时测速清洗| Web end Web -->|自动定时拉取订阅| AGH_Rules AGH_DoH -->|国内域名 + ECS 客户端子网| Domestic_DNS[腾讯 doh.pub / 阿里 DNS] AGH_DoH -->|海外无污染域名| Overseas_DoH[Google DoH / Quad9 ECS] AGH_DoH -->|官方 Cloudflare 域名| CF_Official[直接解析官方节点 + 下发原生 ECH]

2. AdGuard搭建和反代

1. AdGuard Home安装

我这里是用的1panel一键安装的AdGuard Home docker版本, 外部只需要开放853, 80, 3000端口即可

首先通过3000端口完成初始化操作, 然后设置端口为80

2. AdGuard配置

这里只列出主要修改点

/opt/1panel/apps/adguardhome/adguardhome/data/conf/AdGuardHome.yaml

http:
  doh:
    insecure_enabled: true # 这里不是true的话http无法传入
dns:
  upstream_dns_file: /opt/adguardhome/conf/upstream_dns.txt # 这里的txt是通过脚本生成的
  bootstrap_dns:
    - 223.5.5.5
    - 119.29.29.29
    - 1.1.1.1
    - 8.8.8.8
  trusted_proxies:
    - 127.0.0.0/8
    - ::1/128
    - 172.18.0.0/12 # 这里是你的容器ip段
  allowed_clients: # 这里是白名单, 可识别的有ip, 英文字母/数字, 具体可以看官方文档https://github.com/AdguardTeam/AdGuardHome/wiki/Clients#clientid
    - 127.0.0.1
    - ::1
    - eygweh
  edns_client_subnet:
    custom_ip: 
    enabled: true # 这里是EDNS一定要开启
    use_custom: false # 如果你不是本地部署的情况这里不要开
  tls:  
    enabled: true # 如果有需要的话可以使用
    server_name: doh.com
    force_https: false
    port_https: 443
    port_dns_over_tls: 853
    port_dns_over_quic: 853
    port_dnscrypt: 0
    dnscrypt_config_file: ""
    certificate_chain: ""
    private_key: ""
    certificate_path: /opt/adguardhome/conf/fullchain.pem # 证书路径
    private_key_path: /opt/adguardhome/conf/privkey.pem # 私钥路径
    strict_sni_check: false

部分设置也可以通过AdGuard的后台web进行设置

DNS设置:

加密设置:

3. OpenResty反代AdGuard

这里不是必须OpenResty的, Nginx之类的都可以只是配置文件可能有一定的差异

server {
    listen 80 ; 
    listen [::]:80 ; 
    listen 443 ssl ; 
    listen 443 quic ; 
    listen [::]:443 ssl ; 
    listen [::]:443 quic ; 
    server_name doh.com *.doh.com; 
    index index.php index.html index.htm default.php default.htm default.html; 
    access_log /www/sites/doh.com/log/access.log main; 
    error_log /www/sites/doh.com/log/error.log; 
    location ~ ^/(\.user.ini|\.htaccess|\.git|\.env|\.svn|\.project|LICENSE|README.md) {
        return 404; 
    }
    location ^~ /.well-known/acme-challenge {
        allow all; 
        root /usr/share/nginx/html; 
    }
    if ( $uri ~ "^/\.well-known/.*\.(php|jsp|py|js|css|lua|ts|go|zip|tar\.gz|rar|7z|sql|bak)$" ) {
        return 403; 
    }
    location /123456 { # 这里用于加密doh路径, 你可以用你喜欢的英文字母或数字
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    
        # 开启 insecure_enabled: true 后,直接用 http 转发到 AGH 的 Web 端口(请把 3000 换成你实际的 Web 端口)
        proxy_pass http://127.0.0.1:3000/dns-query;
    }
    add_header Alt-Svc 'h3=":443"; ma=2592000'; 
    location / {
        proxy_set_header Host $host; 
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 
        proxy_set_header X-Forwarded-Host $server_name; 
        proxy_set_header X-Real-IP $remote_addr; 
        proxy_set_header X-Forwarded-Proto $scheme; 
        proxy_set_header Connection upgrade; 
        proxy_set_header Upgrade $http_upgrade; 
        proxy_http_version 1.1; 
        proxy_ssl_server_name off; 
        proxy_ssl_name $proxy_host; 
        proxy_pass http://127.0.0.1:3000;  # 这里也是AGH的web端口
    }
    http2 on; 
    if ($scheme = http) {
        return 301 https://$host$request_uri; 
    }
    ssl_certificate /www/sites/doh.com/ssl/fullchain.pem; 
    ssl_certificate_key /www/sites/doh.com/ssl/privkey.pem; 
    ssl_protocols TLSv1.3 TLSv1.2; 
    ssl_ciphers ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA256:!aNULL:!eNULL:!EXPORT:!DSS:!DES:!RC4:!3DES:!MD5:!PSK:!KRB5:!SRP:!CAMELLIA:!SEED; 
    ssl_prefer_server_ciphers off; 
    ssl_session_cache shared:SSL:10m; 
    ssl_session_timeout 10m; 
    error_page 497 https://$host$request_uri; 
    proxy_set_header X-Forwarded-Proto https; 
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains"; 
}

4. 利用EDNS来智能分流

这个脚本用于生成上文用的上游DNS服务器, 不要使用Cloudflare的doh, 官方为了保护隐身强制丢弃了所有ECS客户端子网信息, 通过Cloudflare的doh解析国内CDN之类的会被判定为境外

#!/bin/bash
CONF_DIR="/opt/1panel/apps/adguardhome/adguardhome/data/conf"
UPSTREAM_FILE="${CONF_DIR}/upstream_dns.txt"
TEMP_FILE="${CONF_DIR}/upstream_dns.tmp"

# 1. 默认国外上游(支持 EDNS ECS 的 Google 与 Quad9)
cat << 'EOF' > $TEMP_FILE
https://dns.google/dns-query
https://dns11.quad9.net/dns-query
EOF

# 2. 国内域名列表(匹配任意原上游 IP,统统抹除并替换为腾讯与阿里 DoH)
curl -sL https://raw.githubusercontent.com/felixonmars/dnsmasq-china-list/master/accelerated-domains.china.conf | \
  sed 's|server=/\([^/]*\)/.*|[/\1/]https://doh.pub/dns-query https://dns.alidns.com/dns-query|g' >> $TEMP_FILE

# 3. 苹果与微软国内 CDN 规则
curl -sL https://raw.githubusercontent.com/felixonmars/dnsmasq-china-list/master/apple.china.conf | \
  sed 's|server=/\([^/]*\)/.*|[/\1/]https://doh.pub/dns-query https://dns.alidns.com/dns-query|g' >> $TEMP_FILE

# 4. 校验并重启生效
LINE_COUNT=$(wc -l < $TEMP_FILE)
if [ "$LINE_COUNT" -gt 10000 ]; then
    mv $TEMP_FILE $UPSTREAM_FILE
    echo "分流规则更新成功,共 ${LINE_COUNT} 条规则,正在重启 AdGuardHome..."
    docker restart $(docker ps -qf "name=adguardhome")
else
    rm -f $TEMP_FILE
    echo "下载失败或规则数量异常,保留原配置。"
    exit 1
fi

3. 国内探针

由于海外 VPS 无法准确测量国内不同运营商至境外 CDN 的实际丢包和延迟,因此将测速与清洗工作部署在国内腾讯云 VPS上。

简要说明, 如果你的本地或vps配置了代理一定要让脚本跳过代理, 否则得到的ip可能是无法连通的
不要使用ICMP/TCP Ping, 这样得到的结果不一定可用, 一定要用真实HTTP/HTTPS连接有效
github是有部分ip被官方封锁的一定要注意

1. 深度优化原则与避坑

  1. 废弃虚假的 ICMP/TCP Ping,全面改用端到端 HTTPS 应用层探测:

    • Ping 测的 ICMP 单包仅 64 字节,即使跨洋丢包率达 30%,偶尔溜过一个包也会显示“通(300ms)”;

    • 真实网页需要完成 TCP 握手 + TLS 1.3 协商拉取 4KB+ 证书链 + 接收 HTTP 首包。在 300ms 高延迟线路上稍有丢包就会触发指数退避重传,直接引发浏览器超时;

    • 探针实现 test_https_latency,必须验证 TLS 证书合法有效且取得合法 HTTP 响应码才判定节点存活。

  2. 探针强制跳过本地/系统代理:

    • 探针机器若配置了梯子或系统代理(如 127.0.0.1:7890),会导致测出来的是代理速度而非国内直连网络;

    • 脚本开头强制清除所有代理环境变量,并注入 ProxyHandler({}),确保探测结果 100% 对应国内原生网络。

  3. 集成 Dogfight360 (UsbEAm / S302) 核心 CDN IP 池:

    • 提取近 1000 个细分 CDN 节点,利用 Fastly_C 专属网段(167.82.x.52)解决 Steam 预告片与静态卡死,利用 Fastly_D(167.82.x.133)加速 GitHub 文件下载,利用 352 个 Akamai 节点为 Steam 社区与头像加速。

  4. GitHub 403 阻断剔除:

    • 微软亚太 Anycast IP 中的 20.205.243.167 虽 Ping 延迟低,但被 GitHub 官方 WAF 封锁(返回 Access to this site has been restricted (403))。脚本通过深度 HTTP 探测一票否决坏节点,稳定锁定 20.205.243.166。

  5. 语法规范:

    • 统一采用 |domain^$dnstype=A,dnsrewrite=NOERROR;A;ip 语法,避免传统 hosts 将 IPv6(AAAA)解析直接屏蔽导致部分双栈客户端超时。

2. 实际探针代码

下方的代码会暴露出一个端口用于远端拉取规则

通过在ADG web -> 过滤器 -> 黑名单 新增一个 http://ip:端口/rules.txt 这里虽然写的是黑名单, 但实际规则是按照你的文件来的

import urllib.request
import socket
import ssl
import time
import os
import ipaddress
import re
import http.client
import json
import tempfile
from concurrent.futures import ThreadPoolExecutor

# 强制清除系统代理环境变量,确保以国内原生网络环境直连探测与抓取
for _k in (
    'http_proxy', 'https_proxy', 'ftp_proxy', 'all_proxy',
    'HTTP_PROXY', 'HTTPS_PROXY', 'FTP_PROXY', 'ALL_PROXY'
):
    os.environ.pop(_k, None)
os.environ['no_proxy'] = '*'
os.environ['NO_PROXY'] = '*'

# 强制 urllib 全局禁用任何系统/环境变量代理
urllib.request.install_opener(urllib.request.build_opener(urllib.request.ProxyHandler({})))

def test_https_latency(ip, domain, path="/", timeout=2.0):
    """
    使用真实的 HTTPS 端到端连接(TCP握手 + TLS 1.3/SNI 证书校验 + HTTP 首包)
    彻底取代虚假的 ICMP/TCP Ping,真实反映国内端到端访问质量与可用性。
    """
    t0 = time.monotonic()
    try:
        ctx = ssl.create_default_context()
        with socket.create_connection((ip, 443), timeout=timeout) as s:
            with ctx.wrap_socket(s, server_hostname=domain) as tls_s:
                tls_s.sendall(f"HEAD {path} HTTP/1.1\r\nHost: {domain}\r\nUser-Agent: Mozilla/5.0\r\nConnection: close\r\n\r\n".encode())
                with http.client.HTTPResponse(tls_s) as resp:
                    resp.begin()
                    total_ms = (time.monotonic() - t0) * 1000
                    return total_ms, resp.status
    except Exception as e:
        return float("inf"), str(e)

def pick_best_https_parallel(candidates, domain, path="/", timeout=2.0, max_workers=10):
    def _test(ip):
        lat, st = test_https_latency(ip, domain, path=path, timeout=timeout)
        return ip, lat, st

    with ThreadPoolExecutor(max_workers=max_workers) as ex:
        res = list(ex.map(_test, candidates))

    valid = [(ip, lat) for ip, lat, st in res if lat < float("inf") and isinstance(st, int)]
    if not valid:
        return None, float("inf")
    valid.sort(key=lambda x: x[1])
    return valid[0]

def test_github_web_ok(ip):
    """针对 github.com 必须确保返回 200,且页面不包含 WAF 限制提示"""
    try:
        ctx = ssl.create_default_context()
        with socket.create_connection((ip, 443), timeout=3.0) as s:
            with ctx.wrap_socket(s, server_hostname="github.com") as tls_s:
                tls_s.sendall(b"GET / HTTP/1.1\r\nHost: github.com\r\nUser-Agent: Mozilla/5.0\r\nConnection: close\r\n\r\n")
                with http.client.HTTPResponse(tls_s) as resp:
                    resp.begin()
                    if resp.status != 200:
                        return False
                    body_snippet = resp.read(1024).decode("utf-8", errors="ignore")
                    if "Access to this site has been restricted" in body_snippet:
                        return False
                    return True
    except Exception:
        return False

def load_s302_pools():
    pool_path = "/opt/1panel/tools/dns_rules/s302_cdn_pools.json"
    if os.path.exists(pool_path):
        try:
            with open(pool_path, "r", encoding="utf-8") as f:
                return json.load(f)
        except Exception as e:
            print(f"[S302] Load pools error: {e}")
    return {}

def get_gaming_and_services_rules(s302_pools):
    mirrors = [
        "https://raw.githubusercontent.com/Clov614/SteamHostSync/main/Hosts",
        "https://cdn.jsdelivr.net/gh/Clov614/SteamHostSync@main/Hosts"
    ]
    lines = []
    for m in mirrors:
        try:
            req = urllib.request.Request(m, headers={"User-Agent": "Mozilla/5.0"})
            with urllib.request.urlopen(req, timeout=8) as resp:
                if resp.status == 200:
                    text = resp.read().decode("utf-8")
                    candidate_lines = []
                    for line in text.splitlines():
                        line = line.strip()
                        if line and not line.startswith("#") and "github" not in line.lower():
                            candidate_lines.append(line)
                    if not parse_hosts(candidate_lines):
                        raise ValueError("Source returned no valid hosts entries")
                    lines = candidate_lines
                    print(f"[Gaming/Services] Fetched {len(lines)} base rules from {m}")
                    break
        except Exception as e:
            print(f"[Gaming/Services] Mirror {m} failed: {e}")
            
    if not parse_hosts(lines):
        raise RuntimeError("Gaming rules download failed; keeping the published file")

    # 1. 针对 Steam App UI 关键依赖进行真实 HTTPS 端到端优选
    steam_ui_endpoints = {
        "store.steampowered.com": ["184.30.158.110", "23.61.212.219", "23.54.42.115"],
        "api.steampowered.com": ["2.23.172.187", "23.215.11.239"],
        "store.akamai.steamstatic.com": ["23.46.23.70", "104.95.111.50"],
        "shared.akamai.steamstatic.com": ["23.46.23.70", "104.95.111.106"],
        "cdn.akamai.steamstatic.com": ["23.214.95.212", "23.46.23.10"]
    }
    print("[Steam] Optimizing Steam App UI & static endpoints via real HTTPS...")
    for domain, ips in steam_ui_endpoints.items():
        best_ip, lat = pick_best_https_parallel(ips, domain, timeout=2.5)
        if best_ip:
            print(f"  {domain} -> {best_ip} ({lat:.1f}ms)")
            lines = [l for l in lines if not any(domain in l for _ in [1])]
            lines.insert(0, f"{best_ip} {domain}")

    # 2. 结合 S302 (Dogfight360) 专属池优化 Steam Fastly_C 视频与 Akamai 图片
    if s302_pools:
        fc_list = s302_pools.get("Fastly_C_IP_List", [])[:25]
        if fc_list:
            best_fc, lat_fc = pick_best_https_parallel(fc_list, "video.fastly.steamstatic.com", timeout=2.0)
            if best_fc:
                print(f"  [S302 Fastly_C] video.fastly.steamstatic.com -> {best_fc} ({lat_fc:.1f}ms)")
                lines.insert(0, f"{best_fc} video.fastly.steamstatic.com")

        ak_list = s302_pools.get("Akamai_IP_List", [])[:30]
        if ak_list:
            best_ak, lat_ak = pick_best_https_parallel(ak_list, "steamcommunity-a.akamaihd.net", timeout=2.0)
            if best_ak:
                print(f"  [S302 Akamai] steamcommunity-a.akamaihd.net -> {best_ak} ({lat_ak:.1f}ms)")
                lines.insert(0, f"{best_ak} steamcommunity-a.akamaihd.net")

    # 官方短链与验证过的可用节点
    lines.append("23.222.131.51 s.team")
    lines.append("23.15.140.216 steam.tv")
    lines.append("23.35.148.195 valvesoftware.com")
    return lines

def get_github_rules(s302_pools):
    mirrors = [
        "https://raw.hellogithub.com/hosts",
        "https://fastly.jsdelivr.net/gh/521xueweihan/GitHub520@main/hosts"
    ]
    lines = []
    for m in mirrors:
        try:
            req = urllib.request.Request(m, headers={"User-Agent": "Mozilla/5.0"})
            with urllib.request.urlopen(req, timeout=5) as resp:
                if resp.status == 200:
                    text = resp.read().decode("utf-8")
                    for line in text.splitlines():
                        line = line.strip()
                        if line and not line.startswith("#"):
                            lines.append(line)
                    print(f"[GitHub] Fetched {len(lines)} rules from {m}")
                    break
        except Exception as e:
            print(f"[GitHub] Mirror {m} failed: {e}")
            
    if not parse_hosts(lines):
        raise RuntimeError("GitHub rules download failed; keeping the published file")

    # 针对 github.com 候选池,基于真实 HTTPS 业务响应选出最佳节点
    github_candidates = ["20.205.243.166", "20.205.243.168"]
    best_gh_ip = None
    min_lat = float("inf")
    for ip in github_candidates:
        if test_github_web_ok(ip):
            lat, _ = test_https_latency(ip, "github.com", timeout=2.5)
            if lat < min_lat:
                min_lat = lat
                best_gh_ip = ip

    if not best_gh_ip:
        best_gh_ip = "20.205.243.166"

    print(f"[GitHub] Selected verified HTTPS IP for github.com: {best_gh_ip} ({min_lat:.1f}ms)")
    lines = [l for l in lines if not (l.split() and l.split()[-1].lower() == "github.com")]
    lines.insert(0, f"{best_gh_ip} github.com")

    # 结合 S302 (Dogfight360) Fastly_D 优化 GitHub Desktop / Releases 静态资源
    if s302_pools:
        fd_list = s302_pools.get("Fastly_D_IP_List", [])[:25]
        if fd_list:
            best_fd, lat_fd = pick_best_https_parallel(fd_list, "desktop.githubusercontent.com", timeout=2.0)
            if best_fd:
                print(f"  [S302 Fastly_D] desktop.githubusercontent.com -> {best_fd} ({lat_fd:.1f}ms)")
                lines.insert(0, f"{best_fd} desktop.githubusercontent.com")

    return lines

def get_docker_rules():
    docker_candidates = {
        "registry-1.docker.io": ["44.212.194.123", "44.195.142.59", "52.86.255.203", "107.20.145.202", "35.171.80.220"],
        "auth.docker.io": ["172.64.144.78", "104.18.43.178", "54.227.20.253"],
        "production.cloudflare.docker.com": ["104.16.97.215", "104.16.98.215", "104.16.99.215", "104.16.100.215"]
    }
    lines = []
    print("[Docker] Testing Docker Hub endpoints via real HTTPS...")
    for domain, ips in docker_candidates.items():
        best_ip, lat = pick_best_https_parallel(ips, domain, timeout=2.5)
        if best_ip:
            print(f"  {domain} -> {best_ip} ({lat:.1f}ms)")
            lines.append(f"{best_ip} {domain}")
    return lines

def parse_hosts(raw_lines):
    entries = {}
    for line in raw_lines:
        parts = line.split("#", 1)[0].split()
        if len(parts) < 2:
            continue
        try:
            ip = ipaddress.ip_address(parts[0])
        except ValueError:
            continue
        if ip.version != 4 or not ip.is_global or ip.is_reserved or ip.is_multicast:
            continue
        for domain in parts[1:]:
            domain = domain.lower().rstrip(".")
            labels = domain.split(".")
            if len(domain) <= 253 and len(labels) >= 2 and all(
                re.fullmatch(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?", label)
                for label in labels
            ):
                entries.setdefault(domain, str(ip))
    return entries

def verify_entry(entry):
    domain, ip = entry
    if domain in ("github.com", "api.github.com"):
        if domain == "github.com":
            if test_github_web_ok(ip):
                return entry
            return None
        lat, st = test_https_latency(ip, domain, timeout=2.5)
        if isinstance(st, int):
            return entry
        return None

    lat, st = test_https_latency(ip, domain, timeout=2.5)
    if isinstance(st, int):
        return entry
    print(f"[Skip] {domain} {ip}: HTTPS test failed ({st})")
    return None

def format_rule(domain, ip):
    return f"|{domain}^$dnstype=A,dnsrewrite=NOERROR;A;{ip}"

def main():
    start_time = time.time()
    s302_pools = load_s302_pools()
    if s302_pools:
        print(f"[S302] Successfully loaded Dogfight360 CDN pools ({len(s302_pools)} sets)")

    raw_lines = []
    raw_lines.extend(get_github_rules(s302_pools))
    raw_lines.extend(get_docker_rules())
    raw_lines.extend(get_gaming_and_services_rules(s302_pools))
    entries = parse_hosts(raw_lines)

    print(f"Validating {len(entries)} host entries via full HTTPS handshake...")
    with ThreadPoolExecutor(max_workers=8) as pool:
        verified = [entry for entry in pool.map(verify_entry, entries.items()) if entry]

    if not verified:
        raise RuntimeError("No validated rules; keeping the published file")

    output_rules = [
        "! ==================================================",
        "! Auto-generated DNS Rules by Tencent Cloud (Domestic)",
        f"! Updated: {time.strftime('%Y-%m-%d %H:%M:%S UTC', time.gmtime())}",
        "! Verification: Full HTTPS Application-Level Handshake & Response",
        "! Powered by: Dogfight360 (S302) CDN Pools + Multi-mirror Aggregation",
        "! ==================================================",
        ""
    ]
    output_rules.extend(format_rule(domain, ip) for domain, ip in verified)
    final_content = "\n".join(output_rules) + "\n"

    target_path = "/opt/1panel/www/sites/49.33.136.214/index/rules.txt"
    with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=os.path.dirname(target_path), delete=False) as f:
        f.write(final_content)
        temporary_path = f.name
    try:
        os.chmod(temporary_path, 0o644)
        os.replace(temporary_path, target_path)
    finally:
        if os.path.exists(temporary_path):
            os.unlink(temporary_path)

    print(f"Successfully generated {target_path} (Validated: {len(verified)}/{len(entries)}, elapsed: {time.time()-start_time:.2f}s)")

if __name__ == "__main__":
    main()

4. AdGuard白名单使用

ADG的白名单叫做客户端id/Client ID, 没有配置客户端id的请求会被拒绝

通过申请一个泛域名证书*.doh.com此时你的dot/dns地址会变为客户端id.doh.com , doh地址为https://doh.com/123456/客户端id

手机通过安全/私人DNS填写的为dot, 浏览器填写的为doh

5. 通过ECH加密可以突破SNI阻断

虽然dot也支持ECH但是安卓底层不支持, 目前ECH只能通过浏览器配置doh来使用
和之前的Cloudflare ECH代理原理类似

1. 突破原理

  • SNI 阻断机制:GFW 依靠在骨干网边界的 DPI 监听 TLS ClientHello。若读到黑名单明文 SNI,立即向双向注入 TCP RST 切断连接。

  • ECH (Encrypted Client Hello) 机制:
    客户端向支持的 DoH 服务器查询 Type 65 (HTTPS) 记录,获取服务器公钥。握手时将真正的域名加密封装在内层,外层明文 SNI 显示为中立的 cloudflare-ech.com,避开 GFW 明文匹配。

2. 客户端开启步骤

  • Chromium 内核 (Chrome / Edge / Brave):

    1. 打开 chrome://flags,将 Encrypted ClientHello 设为 Enabled;

    2. 进入 设置 →→ 隐私和安全 →→ 安全 →→ 开启“使用安全 DNS”;

    3. 选择“自定义”,填入私有 DoH 地址:https://doh.com/123456/...;

    4. 彻底重启浏览器。

  • 验证 ECH 激活:
    访问 https://crypto.cloudflare.com/cdn-cgi/trace,若输出 sni=encrypted 即代表 ECH 已全链路生效。

6. 一些常用验证和测试命令

# 1. 验证香港 OpenResty DoH 端点连通性
curl -i -H "accept: application/dns-message" \
  "https://doh.com/123456/client_id?dns=AAABAAABAAAAAAAABmdpdGh1YgNjb20AAAEAAQ" --output -

# 2. 验证 DoT (853端口) 带 Client ID 解析与证书合法性
openssl s_client -connect ip:853 -servername 123456.doh.com -brief

# 3. 检查国内探针规则文件是否正常下发
curl -sI http://ip:7865/rules.txt | head -n 10

# 4. 测试特定 IP 直连握手与 HTTP 状态
curl -sI --resolve github.com:443:20.205.243.166 https://github.com/ | head -n 5

# 5. 测试 Type 65 HTTPS 记录与 ECH 是否完整下发
dig @1.1.1.1 HTTPS exhentai.org

7. 一些补充内容

一些黑白名单规则, 你可以通过配置文件或者web进行添加修改

filters:
  - enabled: true
    url: https://adguardteam.github.io/HostlistsRegistry/assets/filter_1.txt
    name: AdGuard DNS filter
    id: 1
  - enabled: false
    url: https://adguardteam.github.io/HostlistsRegistry/assets/filter_2.txt
    name: AdAway Default Blocklist
    id: 2
  - enabled: true
    url: http://ip:7865/rules.txt
    name: AdGuard Tencent
    id: 3
  - enabled: true
    url: https://raw.githubusercontent.com/privacy-protection-tools/anti-AD/master/anti-ad-adguard.txt
    name: anti-AD 中文广告防护
    id: 4
  - enabled: true
    url: https://raw.githubusercontent.com/TG-Twilight/AWAvenue-Ads-Rule/main/AWAvenue-Ads-Rule.txt
    name: AWAvenue 秋风开屏与移动广告规则
    id: 5
whitelist_filters:
  - enabled: true
    url: https://raw.githubusercontent.com/privacy-protection-tools/dead-horse/master/anti-ad-white-list.txt
    name: anti-AD 官方防误杀白名单
    id: 6