adguardhome隐蔽提供DoH服务

于 2023-07-29 发布

adguardhome是个好东西,经常被用来装在家里的路由器上,给家里网络去广告。但是如果想把它装到VPS上提供“私有”的DNS服务,那就会麻烦很多——从安装开始就在折磨。还好,折腾了一番之后终于搞好了,所以写下这篇“指南”。

目录结构

首先来介绍目录结构:

  1. nginx目录:/etc/nginx,vhost配置文件目录:/etc/nginx/sites
  2. adguardhome,用docker-compose来搭,按照官方建议有以下结构:
    /opt/adguardhome
    |-- confdir/
    |-- workdir/
    `-- docker-compose.yaml
    

首先参考一下adguardhome的docker页面,主要是看它说明的不同的端口分别是用来做什么的,以决定需要映射什么端口出来:

端口 功能
53 普通DNS
67、68 DHCP功能
853 DoT服务
784、853、8853 DNS over QUIC服务
5443 DNSCrypt服务
80、443、3000 管理页面及DoH服务

可见,我们需要持续用到的是443端口,在安装时需要映射80及3000端口才能访问安装页面。

初步安装

首先从reddit抄一个docker-compose.yaml来(只是看看,别学,用不了的):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
version: "3"
services:
  adguardhome:
    image: adguard/adguardhome
    container_name: adguardhome
    ports:
      - 53:53/tcp
      - 53:53/udp
      - 784:784/udp
      - 853:853/tcp
      - 3000:3000/tcp
      - 3080:80/tcp
      - 3443:443/tcp
      - 3443:443/udp
    volumes:
      - ./workdir:/opt/adguardhome/work
      - ./confdir:/opt/adguardhome/conf
    restart: unless-stopped
    networks:
      agh_net:
        ipv4_address: 172.18.0.2

networks:
  agh_net:
    driver: bridge
    ipam:
      driver: default
      config:
        - subnet: 172.18.0.1/16

这个示例用不了的原因是,version 3不支持对network配置ipam。那可能就会有疑问了,“为什么就得配置这个ip呢,让docker自己配置不行吗”。如果不在意日志里面显示的“客户端地址”都是无意义的docker分配给这个容器的网络的gateway地址的话,那确实是可以不配置的。但是从我的习惯来说,让日志模块正确记录IP是“既然有这个模块,不用白不用”的事情,所以干脆配置一下好了。

现在来理一下思路。首先,安装时需要访问3000端口,所以要映射3000到主机上,为了避免冲突,主机上的端口选择了23000。然后,因为后续要利用nginx的realip模块将真正的客户端IP发给adguardhome,所以要配置固定的子网。那么docker-compose.yaml就应该是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
version: "2"
services:
  adguardhome:
    image: adguard/adguardhome
    container_name: adguardhome
    ports:
      - 0.0.0.0:23000:3000/tcp
    volumes:
      - /opt/adguardhome/workdir:/opt/adguardhome/work
      - /opt/adguardhome/confdir:/opt/adguardhome/conf
    restart: always
    networks:
      - adguardhome

networks:
  adguardhome:
      driver: bridge
      ipam:
          driver: default
          config:
              - subnet: "172.18.0.0/24"
                gateway: "172.18.0.1"

由于restart: always的存在,如果docker服务是开机自启的,那么这个容器也是开机自启的。

其中最后两行的IP地址,可能需要根据自己的机子上现存的docker网络来找个空闲的网段。

/opt/adguardhome下执行docker compose up -d启动容器,然后防火墙打开23000端口(iptables:iptables -I INPUT -p tcp --dport 23000 -j ACCEPT)。此时访问http://服务器IP:23000进行安装,主要是配置管理员用户名和密码——但是因为此时是通过无加密HTTP进行安装的,存在泄露风险,所以这个密码也只是临时用着,之后要改的。安装完成之后会被重定向至无法访问的页面,那么就该下一步了。

先用docker compose down把镜像停掉,后面的步骤需要修改volume挂载等配置,需要重新up。别忘了把临时放行23000端口的防火墙规则删掉(iptables:iptables -D INPUT -p tcp --dport 23000 -j ACCEPT)。

SSL加密

我们提供的是DoH服务,需要SSL证书。同时,我们不希望被exploit,所以不能使用标准的/dns-query路径,需要用nginx做个转发。另外,我们需要访问控制面板,所以需要让nginx把该虚拟主机非DoH查询流量转发给adguardhome。一步步来吧。

SSL与docker映射

先要申请一个SSL证书,这里用的是certbot:

1
certbot certonly --nginx -d 域名

为了方便描述,示例域名就用dns.example.domain来代替吧。上面这一行命令在授权成功之后会输出certificate和private key的路径,一般是:

  1. 证书:/etc/letsencrypt/live/dns.example.domain/fullchain.pem
  2. 私钥:/etc/letsencrypt/live/dns.example.domain/privkey.pem

为了让容器内的adguardhome能够访问到这些文件,需要在docker-compose.yaml里新增一行volume映射。另外,adguardhome一旦配置了HTTPS,控制面板就不是用3000端口来访问了,而是用443端口访问了。所以直接一步到位,把端口映射也改好:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
version: "2"
services:
  adguardhome:
    image: adguard/adguardhome
    container_name: adguardhome
    ports:
      # - 0.0.0.0:23000:3000/tcp
      - 127.0.0.1:23443:443/tcp
      - 127.0.0.1:23443:443/udp
    volumes:
      - /opt/adguardhome/workdir:/opt/adguardhome/work
      - /opt/adguardhome/confdir:/opt/adguardhome/conf
      - /etc/letsencrypt:/opt/adguardhome/certs:ro
    restart: always
    networks:
      - adguardhome

networks:
  adguardhome:
      driver: bridge
      ipam:
          driver: default
          config:
              - subnet: "172.18.0.0/24"
                gateway: "172.18.0.1"

那可能又会有疑问了,“为什么要把整个letsencrypt目录都映射进去呢”。原因是/etc/letsencrypt/live里面的是链接,真正的文件在另外的目录里。为了减少麻烦,直接整个目录用只读(ro)模式挂在进去容器里更好。

先别急着docker compose up,现在要手动修改adguardhome的配置文件才能启用HTTPS。

修改adguardhome配置

之前说过,adguardhome的配置目录被映射到了/opt/adguardhome/confdir。这里面有个AdGuardHome.yaml,打开来修改:

  1. trusted_proxies,新增两条:172.18.0.1172.0.0.0/8(分别是本容器网络的gateway和docker全部网络的网段)。这个是为了获取客户端IP的,或许也有安全作用?

  2. tls部分:

    配置项 改成这个值
    enabled true
    server_name dns.example.domain
    force_https true
    port_https 443
    certificate_path /opt/adguardhome/certs/live/dns.example.domain/fullchain.pem
    private_key_path /opt/adguardhome/certs/live/dns.example.domain/privkey.pem

修改好之后就可以docker compose up了,注意看一下输出的日志里SSL相关的条目,正常来说不会显示错误的,都是[info]。容器本身没问题的话,就可以先Ctrl+C,再docker compose start来让容器后台运行了。此时还是没办法访问到面板,DoH服务也还不能用,因为需要用nginx来转发。

nginx转发

首先需要说明的是,我的nginx是用proxy_protocol的,所以有些变量名字不太一样。

nginx需要负责的是:

  1. 对标准路径/dns-query的所有查询进行拒绝,使用HTTP状态码403 Forbidden回复
  2. 对某个自定义的路径,转发给adguardhome的/dns-query做DNS查询。这样就实现了只有自己知道什么路径才是真正的DoH服务,实现了防滥用。建议这个自定义路径搞长一点,防护力更强。值得一提的是,“路径”属于URI,是被TLS加密的一部分,不用担心被看到。
  3. 不符合上面规则的,都转发给adguardhome的根路径,让它自己处理。

拒绝标准路径

1
2
3
location /dns-query {
    return 403;
}

这一步是必要的,因为如果不特别指明一个location来针对性处理/dns-query,就会导致location /匹配上这个路径而转发给adguardhome,导致标准路径也会得到DNS回应。

自定义路径DoH

假设我们想让DoH客户端们用https://dns.example.domain/xH4XzBLJNVqkS6来获得该服务,那么nginx的配置就是:

1
2
3
4
5
6
7
8
9
10
location ^~ /xH4XzBLJNVqkS6 {
    proxy_redirect off;
    proxy_buffering off;
    proxy_http_version 1.1;
    proxy_set_header Host $http_host;
    # show real IP
    proxy_set_header X-Real-IP $proxy_protocol_addr;
    proxy_set_header X-Forwarded-For $proxy_protocol_addr;
    proxy_pass https://127.0.0.1:23443/dns-query;
}

FINAL规则

1
2
3
4
5
6
7
8
9
location / {
    proxy_ssl_server_name on;
    proxy_ssl_name $host;
    proxy_pass https://127.0.0.1:23443/;
    proxy_set_header X-Real-IP $proxy_protocol_addr;
    proxy_set_header X-Forwarded-For $proxy_protocol_addr;
    proxy_ssl_session_reuse on;
    proxy_http_version 1.1;
}

整个vhost的配置文件示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
server {
  listen 8443 ssl http2 proxy_protocol;
  server_name dns.example.domain;
  root /dev/null;
  # ssl证书地址
  ssl_certificate /etc/letsencrypt/live/dns.example.domain/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/dns.example.domain/privkey.pem;

  client_body_buffer_size 512k;

  include /etc/nginx/mime.types;

  location /dns-query {
    return 403;
  }

  location ^~ /xH4XzBLJNVqkS6 {
    proxy_redirect off;
    proxy_buffering off;
    proxy_http_version 1.1;
    proxy_set_header Host $http_host;
    # show real IP
    proxy_set_header X-Real-IP $proxy_protocol_addr;
    proxy_set_header X-Forwarded-For $proxy_protocol_addr;
    proxy_pass https://127.0.0.1:23443/dns-query;
  }

  location / {
    proxy_ssl_server_name on;
    proxy_ssl_name $host;
    proxy_pass https://127.0.0.1:23443/;
    proxy_set_header X-Real-IP $proxy_protocol_addr;
    proxy_set_header X-Forwarded-For $proxy_protocol_addr;
    proxy_ssl_session_reuse on;
    proxy_http_version 1.1;
  }
}

还是那句话,如果你的nginx没有开proxy_protocol,请自己改动listen的配置以及所有的proxy_set_header X-Real-IPproxy_set_header X-Forwarded-For

把这个内容放到/etc/nginx/sites/dns.example.domain.conf里,重新加载nginx配置(nginx -t && systemctl reload nginx)。此时就应该可以通过https://dns.example.domain来访问到控制面板;配置DoH客户端的服务器URL为https://dns.example.domain/xH4XzBLJNVqkS6就应该能够获得正确的DNS回应1

改密码

其实登录界面有个“忘记密码”可以点。主要命令是htpasswd -B -n -b <USERNAME> <PASSWORD>。根据输出内容自己修改/opt/adguardhome/confdir/AdGuardHome.yaml,然后docker compose restart即可。

零零碎碎

过滤器规则目录:adblockfilters

参考资料

docker网络配置:固定子网IP

获取真实客户端IP:Unable to see real client IP address for DoT/DoHContainer shows only the Docker network’s IP as the Client in logs

iptables简易指南:How To List and Delete Iptables Firewall Rules

映射SSL证书给容器:Adguard Home Docker Cert folder Pathing

架设adguardhome的综合教程:AdGuardHome 自建公共DNS服务器

  1. 用curl来测试DoH:curl --doh-url https://dns.example.domain/xH4XzBLJNVqkS6 https://www.baidu.com,成功的话会输出百度首页的HTML 

目录