Skip to content

Harden DNS forwarding: per-server transport, network rules, SOCKS5, certificates, query log and backup - #133

Open
fa1seut0pia wants to merge 18 commits into
iTXTech:masterfrom
fa1seut0pia:master
Open

fa1seut0pia wants to merge 18 commits into
iTXTech:masterfrom
fa1seut0pia:master

Conversation

@fa1seut0pia

Copy link
Copy Markdown

重写 DNS 转发层,并补上一批面向内网、自建 DNS 场景的功能。

新增能力

转发

  • 协议按服务器自动选择:853 端口走 DoT,带路径的地址走 DoH,其余按设置走 UDP 或 TCP。首选 DoT、备用 DoH 可以混用,不再受全局"查询方式"限制。自定义服务器可在编辑页把协议固定为 UDP 或
    TCP(默认"自动"),853 端口也照此覆盖;经 SOCKS5 代理时始终走 TCP。
  • 应用内故障转移:每个查询先问首选,2 秒无果改为备用,整条链在 4.5 秒内必有结果或返回 SERVFAIL,不再依赖系统解析器的超时和"服务器不可用"记忆。
  • DoT / TCP 长连接复用;查询 socket 绑定到当前实际使用的网络,切网瞬间不会走错口;用域名配置的 DoT 带 SNI 并校验证书主机名。

网络规则

  • 按所在网段(支持多个 CIDR)或移动数据自动选择首选/备用服务器,切网即时生效,不重建 VPN;都不匹配时使用设置里的默认首选/备用。
  • 典型用法:家里 WiFi 网段 → 内网 SmartDNS(DoT 853);其他网络 → 公网 DoH。

服务器

  • 自定义服务器可经 SOCKS5 代理连接,代理地址和账号按服务器各自配置,例如 EasyTier 非 TUN 模式暴露的 127.0.0.1:1080,用来访问虚拟网络内的 DNS。代理只承载 TCP,这类服务器的 UDP 查询自动改走
    TCP。服务器编辑页有"测试连接"按钮,按当前填写内容直接试一次查询。
  • 自定义服务器可绑定 TLS 证书(自签名或私有 CA,例如 SmartDNS 自动生成的 smartdns-cert.pem),只对这台服务器生效,其余服务器仍只信任系统 CA。
  • 内置服务器可在 设置 → 服务器管理 里逐个关闭;自定义服务器排在列表顶部,最新添加的最上。

可观测

  • 查询记录页:每条查询的域名、类型、命中的规则、回答的服务器与协议、结果 IP、耗时;首选失败转移到备选时附上失败原因。按域名过滤,自动刷新,保留最近 300 条。
  • 测试页:停止按钮、正确的连接和读超时、结果标注实际使用的协议。

配置

  • 配置改存内部存储、临时文件加原子重命名写入,解析失败保留坏文件,修复了自定义服务器偶发丢失的问题;增删改后立即保存并被运行中的 VPN 采用。
  • 设置 → 备份与恢复:导出 / 导入 / 从 URL 导入,一个 JSON 文件覆盖自定义服务器(含证书)、默认首选备用、网络规则、过滤规则、应用列表和全部设置。导入为合并更新,不删除已有内容;服务器以
    builtin:<地址> / custom:<地址>:<端口> 引用,换机可用。

其他

  • 主题:跟随系统 / 暗色 / 亮色。每个设置项都带用途说明。
  • 修复:文件选择器返回时误启动 VPN;非高级模式下选 DoH 服务器无法启动;TCP 响应短读;测试页额外服务器解析错误。

使用要点

  • 以上能力都需要打开 设置 → 高级系统设置 → 开启。关闭时应用只是把服务器 IP 交给系统做普通 DNS,端口、加密、代理、证书和规则都不生效。
  • 走 SOCKS5 代理的 DNS 服务器需要开放 TCP 53(SmartDNS 加 bind-tcp [::]:53)或改用 853。
  • 内网 DNS 请用 IP 配置。VPN 启动和切网时会先在底层网络上解析服务器域名,不会绕回 VPN 自身。

The plugin is hosted on fabric.io / Bintray, which no longer resolve, so
the build script failed before it reached any task. Its task filter in
app/build.gradle goes with it.

AGP 7.2.0 and the refreshed wrapper scripts (network timeout, xargs
check) are what the build needs on JDK 17.
The release build type had no signing config, so assemblePureRelease
produced an unsigned APK. Read the keystore from local.properties, which
is not in the repository, and apply the config only when the file is
there: file(null) aborts the build script and would break debug builds
and CI, where local.properties does not exist.
upload-artifact v3 was retired by GitHub, so the workflow failed
immediately instead of uploading the APK. The v4 action and a manual
trigger are what makes the CI usable again.
…cally

The configuration lived in the external files directory, which is not
always mounted when the process starts (right after boot, for instance).
Configurations.load() then fell back to a fresh default object without
saying so, and the next save overwrote the real file: custom DNS servers
disappeared now and then.

Move the file to internal storage and move an existing external one over
on first run. Write to a temporary file and rename it over the real one,
so a process killed mid-save cannot leave a truncated file behind, and
keep a file that cannot be parsed as config.json.corrupt instead of
overwriting it on the next save.
The missing Chinese strings of the upstream project, and the few English ones that
had drifted, brought in line.
The primary and secondary servers can now depend on the network: a rule matches a
set of subnets (several CIDR ranges) or mobile data, and carries its own pair of
servers. The settings keep the default pair for the networks no rule matches.

Rules are edited in Settings > Network Rules, each rule on a page of its own, and
stored in the configuration file.
Every built-in server has an on/off switch in Settings > Server Management. A server
that is switched off keeps its ID, so a saved selection stays valid, but is hidden from
every list. The state is remembered under a key made from the address, so reordering
the built-in list does not move the switches around.

The server lists are now built in one place, DnsServerHelper.getAllServers(): the custom
servers first, the most recently added on top, then the enabled built-in ones. This is
also what makes a selection fall back to an available server when the selected one was
switched off or deleted.

Three built-in servers are added: Aliyun over DoT and over DoH, and a Cloudflare
Gateway.
A custom server can be configured to be reached through a SOCKS5 proxy, with the proxy
address, port and credentials stored per server instead of once for all of them. This is
what makes a DNS server inside a virtual network (EasyTier's, for instance) usable.

A SOCKS5 proxy only carries TCP, so UDP queries to a proxied server are sent over TCP.
Servers flagged as proxied by an older version get the settings of that time copied in
once, then the old keys are dropped.

The server page gained a Test connection button that sends one query with the values as
they are filled in, without saving them.
DNS over TLS connections to a custom server can now be validated against a certificate
imported from a file (PEM or DER) in addition to the system CAs, which is what a SmartDNS
or another self-hosted resolver with a self-signed certificate needs. The certificate is
stored per server and only used for that server. Imported certificates that no server
refers to any more are deleted.
The six per-protocol providers are replaced by one place that knows how to send a query
to a server: DnsTransport. It picks the protocol from the server itself, 853 goes to DNS
over TLS, an address with a path to DNS over HTTPS, the rest follows the query method
setting, and it keeps DNS over TLS and TCP connections open to reuse them. A proxy is
honoured for every protocol.

UnifiedProvider answers the VPN's queries with it and adds failover: a query goes to the
primary server, and to the secondary one after 2 seconds without an answer; the whole
chain answers or fails within 4.5 seconds instead of waiting for the system resolver's own
timeouts. Query sockets are bound to the network actually in use, so a network change
does not send them out of the wrong one.

The service follows the networks the system reports and re-applies the servers chosen by
the network rules, which is what makes switching networks take effect without rebuilding
the VPN. DoH host names are resolved on the underlying network so that the lookup never
goes through the VPN itself.

A custom server can also be pinned to plain DNS over UDP or TCP, which wins over the
choice made from the port: an internal resolver on 5300, or a TCP-only one, can then be
used as is. Automatic keeps the choice Daedalus makes.
The log of the run is mirrored to logs/daedalus.log while the app runs and trimmed once
it grows past 256 KB. The in-memory buffer alone is lost with the process, and a process
killed by the system (ANR, low memory, force stop) left no trace at all, which made such
reports impossible to look at.

Uncaught exceptions of every thread are appended to logs/crash.log, and the Log page shows
the recorded crashes in front of the previous run's log. Both files are cleared together
with the buffer.

Exporting the log asked the system where to put the file instead of writing it next to the
app's private files under Android/data, where no file manager shows it.
A Query Log page lists the last 300 queries with the domain, the type, the rule that
matched, the server and protocol that answered, the addresses of the answer and how long it
took, and the reason the primary server was given up on when the query went to the
secondary one. It can be filtered by domain and refreshed or cleared from the toolbar.
Settings > Backup & Restore writes the custom servers with their certificates, the
default pair, the network rules, the filter rules, the app list and every setting to one
JSON file, and merges such a file back in: existing items are updated, nothing is deleted.
A file can also be downloaded from a URL, for a configuration hosted somewhere.

Servers are referenced by their kind and address (builtin:<address>,
custom:<address>:<port>) rather than by position, so a file written on one phone means the
same thing on another.

The VPN permission result is now told apart from the results of the file pickers the
fragments started, which used to start the service on their own.
The app filter used getInstalledPackages(GET_PERMISSIONS) and showed whatever that
returned, which on Android 11 is the system apps only: every user app was missing and
could not be selected.

An app sees the packages matching its <queries> declaration instead. The declaration added
here lists the components a routable app can have, which brings back 90 of 94 user apps on
the device this was measured on; the app itself is now listed too, the search box filters
by name and package, the package name is shown under the app name (marked as a system app
when it is), and the apps are sorted user first, then by name in the current locale.
The Test page can be stopped, and a test that is running is no longer started twice. The
connection and read timeouts are 5 seconds rather than the minidns defaults, the extra
servers of the settings are parsed one by one instead of from the whole list (an entry with
a port used to break all of them), the result shows the protocol that answered, and a
timeout or an error is reported as such.

The extra test servers default to public resolvers instead of the VPN's own alias
addresses, which only answer while the VPN runs; installs still carrying the old value are
moved to the new default once.
Every switch and entry of the settings shows what it is for, and the entries that are off
by default now say so instead of appearing broken: the count in the notification is
enabled, and the cache entry of the removed "don't cache IP" option is gone.
The dark theme switch is replaced by a choice between following the system, dark and
light. A switch that was on becomes the dark choice once, then the old key is unused. The
activity is rebuilt when the system switches between day and night while the theme follows
it, which arrives as a uiMode change rather than as a restart.
Starting the service from a quick settings tile or another app made the system kill the
app a few seconds later: the service was started with startForegroundService(), which
obliges it to call startForeground() promptly, and it did not. The activation intent now
carries EXTRA_FOREGROUND, the service promotes itself before doing anything else, and it
does so on every such start even when it is already running, as each start has its own
pending request.

The notification id was 0, which startForeground() reads as a request to stop the
foreground state; it is 1 now.

Restarting the service is also handled: when the system restarts it after killing the
process there is no intent to replay and the statics are gone, so the VPN re-establishes
itself from the settings, back in the foreground if it ran there. Starting a service from
the background can be refused on Android 8+, which now logs the reason instead of crashing.

The crash handler is installed here as well, so an exception during the start is recorded
before the process dies.

@PeratX PeratX left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

审计了当前提交 2322b2bfe2747a83a7c5612a35e136eee5b32648,基于 master 的 0deea908d99dd4665efab8523841670bde66b21f。

共用传输层、短读处理、配置原子写入以及文件选择回调修复的方向是合理的,但当前版本存在以下合并阻塞项和功能缺陷,因此本次评审为 Request changes。请修复并补充回归验证后再合并。

  1. P1 — 备份规则导入没有限制写入目录。 ConfigBackup.importRules() 直接将导入的 fileName 拼接进规则文件路径并打开 FileOutputStream,没有校验简单文件名或规范化后的目录归属。导入来源可以是文件或 URL,因而不应把这个字段视为可信路径。可能覆盖规则目录以外、应用有权写入的文件。应先验证文件名和 canonical path 的目录边界,使用暂存文件与原子提交。仅静态审计,没有构造或执行越界写入。

    ConfigBackup.java:372

  2. P1 — 附加自定义证书会跳过 DoT 的服务器身份校验。 wrapTls() 仅在 host 非 IP 且 certificate 为 null 时检查主机名;附加证书后不再检查。与此同时 CompositeTrustManager 仍先接受系统 CA,而非将信任严格限定到附加的服务器证书。结果是“导入该服务器的证书”没有保证证书属于目标服务器;私有 CA 模式也没有验证目标名称。应区分 CA 信任与叶证书固定:CA 模式保留 DNS/IP 身份校验,叶证书模式显式固定证书或公钥。旧 TlsProvider 本身也欠缺身份校验,因此不将所有 IP 场景描述为新引入回归;这里针对的是新增自定义证书功能的信任语义。

    DnsTransport.java:286;TlsCertificates.java:277

  3. P1 — 4.5 秒查询总期限并未实现。 remaining 只在每次尝试前计算一次,TCP 连接、TLS 握手、读取会重复使用超时;DoH 只设置 connect/read/write timeout,没有整次 call timeout;remaining 已耗尽时还通过 Math.max 保证至少再尝试 1 秒。慢网络或多阶段耗时可突破承诺的期限,延迟备用切换和 SERVFAIL。需要单调时钟的统一 deadline、每一步扣减剩余预算,并在到期时主动取消正在阻塞的 socket/HTTP call。

    UnifiedProvider.java:250;DnsTransport.java:356。OkHttp 4.9.3 源码对 callTimeout/readTimeout 的定义也明确区分整个调用期限和单次读取超时;本次通过原始源码核实默认 call timeout 为 0。

  4. P1 — DNS 查询并发没有上限或背压。 每个请求都交给 newCachedThreadPool,一个慢查询占用一个阻塞线程;线程池没有限制工作线程数量,活动连接也没有总量限制(MAX_IDLE_PER_SERVER 只限制空闲连接)。上游故障期间,正常应用并发和系统重试就可能累积大量线程/socket,进一步拖垮 VPN。应使用有界线程池、有限排队与全局/每上游在途请求限额,并定义拒绝或过载响应;停止服务时应关闭在途 I/O。未做设备压力测试。

    UnifiedProvider.java:61;请求提交位置

  5. P2 — 切网后,旧的在用连接可以重新进入连接池。 setServers 调用 closeAll,但 closeAll 只清空 idle;已借出的连接完成后会被 release 再次放回。池 key 不含 Network 或配置代次。当切网前后仍使用同一上游时,后续查询可复用绑定旧网络的连接;旧网仍可用时继续走旧网,断开时增加失败和超时。用连接池代次或不可变网络/配置快照隔离,并在归还时关闭旧代次连接。隔离 JVM 检查确认旧连接确实能重新入池;未将其声称为 Android 实机切网结果。

    DnsConnectionPool.java:109;release;closeAll

  6. P2 — DoH 的协议识别错误地检查整个地址中的 resolve 子串。 resolver.example/dns-query 这样的标准 wire-format DoH 地址会被当作 JSON API;原有全局协议选择被删除后也不能手动纠正。应把协议作为明确的服务器配置,至少基于解析后的路径精确识别,并允许覆盖。提取 PR 实际方法的 JVM 检查得到预期 IETF=3、实际 JSON=4,普通 dns.example/dns-query 对照通过;没有访问这些示例域名。

    DnsTransport.java:135

  7. P2 — 主备去重忽略传输协议和代理配置。 sameServer 仅比较地址、端口、是否使用代理。相同端点分别强制 UDP/TCP,或使用不同 SOCKS5 代理的两个配置,会被折叠,备用服务器根本不进入查询链。应基于有效传输、代理端点/认证、TLS 配置等完整连接身份比较,或按显式服务器 ID 保留用户选择。提取实际 sameServer 方法的 JVM 检查确认 UDP/TCP 被误判相同。

    UnifiedProvider.java:138

  8. P2 — 上游域名的一次初始化解析失败可能持续到下次网络/配置变化。 prepare 捕获 UnknownHostException 后返回未解析对象,applyServers 仍记录 currentSelection;同一网络与配置的后续回调会在相同状态检查处直接返回。转发线程又被禁止解析域名,因此底层 DNS 恢复后也没有主动恢复机制。如果主备均使用域名且初始化均失败,会持续无法解析。应区分“选中配置”和“已成功准备”,对暂时失败设置有界重试与退避,而非缓存为完成状态。

    DaedalusVpnService.java:488;prepare 失败处理

工程与验证要求

本 PR 涉及 60 个文件,新增 5,628 行、删除 1,764 行,覆盖转发重写、网络策略、代理、证书、备份、主题及构建系统。建议将构建修复、配置存储、转发核心与可选功能拆分评审,降低回归定位和回滚成本。

目前没有新增 test / androidTest 源码,PR 的 GitHub 检查结果为空;修改后的 workflow 仍只监听 push / workflow_dispatch,没有 pull_request 触发,构建任务也仅为 assemblePureDebug。请增加 PR CI,并覆盖正常及超时主备切换、切网时在途请求、协议识别、证书身份校验、备份导入边界与失败原子性、旧配置迁移、服务停止和重启。

本次验证边界:

  • 已完成源码与调用链审查,并对照 master 区分新增问题和历史遗留问题。
  • 56 个 XML 文件解析通过,这不等于 Android 资源编译或 lint 通过。
  • 在隔离 JVM 中,使用 PR 原始 AbstractDnsServer / DnsConnectionPool 以及从原源码提取的 getMethod / sameServer,其余依赖使用桩:4 个断言中正常对照通过,3 个预期行为断言失败,分别确认第 5、6、7 项。这不是 Android 端到端验证,也未进行网络压力测试。
  • 本次没有完成 APK 构建、Android lint 或模拟器/真机测试,因此不能据此认定构建或设备兼容性通过。

另外,gradlew 第 147、155 行有尾随空白,可顺手清理,属于非阻塞项。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants