Repository navigation
Harden DNS forwarding: per-server transport, network rules, SOCKS5, certificates, query log and backup - #133
fa1seut0pia wants to merge 18 commits into
Conversation
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
left a comment
There was a problem hiding this comment.
审计了当前提交 2322b2bfe2747a83a7c5612a35e136eee5b32648,基于 master 的 0deea908d99dd4665efab8523841670bde66b21f。
共用传输层、短读处理、配置原子写入以及文件选择回调修复的方向是合理的,但当前版本存在以下合并阻塞项和功能缺陷,因此本次评审为 Request changes。请修复并补充回归验证后再合并。
-
P1 — 备份规则导入没有限制写入目录。
ConfigBackup.importRules()直接将导入的 fileName 拼接进规则文件路径并打开 FileOutputStream,没有校验简单文件名或规范化后的目录归属。导入来源可以是文件或 URL,因而不应把这个字段视为可信路径。可能覆盖规则目录以外、应用有权写入的文件。应先验证文件名和 canonical path 的目录边界,使用暂存文件与原子提交。仅静态审计,没有构造或执行越界写入。 -
P1 — 附加自定义证书会跳过 DoT 的服务器身份校验。
wrapTls()仅在 host 非 IP 且 certificate 为 null 时检查主机名;附加证书后不再检查。与此同时 CompositeTrustManager 仍先接受系统 CA,而非将信任严格限定到附加的服务器证书。结果是“导入该服务器的证书”没有保证证书属于目标服务器;私有 CA 模式也没有验证目标名称。应区分 CA 信任与叶证书固定:CA 模式保留 DNS/IP 身份校验,叶证书模式显式固定证书或公钥。旧 TlsProvider 本身也欠缺身份校验,因此不将所有 IP 场景描述为新引入回归;这里针对的是新增自定义证书功能的信任语义。 -
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。
-
P1 — DNS 查询并发没有上限或背压。 每个请求都交给 newCachedThreadPool,一个慢查询占用一个阻塞线程;线程池没有限制工作线程数量,活动连接也没有总量限制(MAX_IDLE_PER_SERVER 只限制空闲连接)。上游故障期间,正常应用并发和系统重试就可能累积大量线程/socket,进一步拖垮 VPN。应使用有界线程池、有限排队与全局/每上游在途请求限额,并定义拒绝或过载响应;停止服务时应关闭在途 I/O。未做设备压力测试。
-
P2 — 切网后,旧的在用连接可以重新进入连接池。 setServers 调用 closeAll,但 closeAll 只清空 idle;已借出的连接完成后会被 release 再次放回。池 key 不含 Network 或配置代次。当切网前后仍使用同一上游时,后续查询可复用绑定旧网络的连接;旧网仍可用时继续走旧网,断开时增加失败和超时。用连接池代次或不可变网络/配置快照隔离,并在归还时关闭旧代次连接。隔离 JVM 检查确认旧连接确实能重新入池;未将其声称为 Android 实机切网结果。
-
P2 — DoH 的协议识别错误地检查整个地址中的 resolve 子串。
resolver.example/dns-query这样的标准 wire-format DoH 地址会被当作 JSON API;原有全局协议选择被删除后也不能手动纠正。应把协议作为明确的服务器配置,至少基于解析后的路径精确识别,并允许覆盖。提取 PR 实际方法的 JVM 检查得到预期 IETF=3、实际 JSON=4,普通dns.example/dns-query对照通过;没有访问这些示例域名。 -
P2 — 主备去重忽略传输协议和代理配置。 sameServer 仅比较地址、端口、是否使用代理。相同端点分别强制 UDP/TCP,或使用不同 SOCKS5 代理的两个配置,会被折叠,备用服务器根本不进入查询链。应基于有效传输、代理端点/认证、TLS 配置等完整连接身份比较,或按显式服务器 ID 保留用户选择。提取实际 sameServer 方法的 JVM 检查确认 UDP/TCP 被误判相同。
-
P2 — 上游域名的一次初始化解析失败可能持续到下次网络/配置变化。 prepare 捕获 UnknownHostException 后返回未解析对象,applyServers 仍记录 currentSelection;同一网络与配置的后续回调会在相同状态检查处直接返回。转发线程又被禁止解析域名,因此底层 DNS 恢复后也没有主动恢复机制。如果主备均使用域名且初始化均失败,会持续无法解析。应区分“选中配置”和“已成功准备”,对暂时失败设置有界重试与退避,而非缓存为完成状态。
工程与验证要求
本 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 行有尾随空白,可顺手清理,属于非阻塞项。
重写 DNS 转发层,并补上一批面向内网、自建 DNS 场景的功能。
新增能力
转发
TCP(默认"自动"),853 端口也照此覆盖;经 SOCKS5 代理时始终走 TCP。
网络规则
服务器
127.0.0.1:1080,用来访问虚拟网络内的 DNS。代理只承载 TCP,这类服务器的 UDP 查询自动改走TCP。服务器编辑页有"测试连接"按钮,按当前填写内容直接试一次查询。
smartdns-cert.pem),只对这台服务器生效,其余服务器仍只信任系统 CA。可观测
配置
builtin:<地址>/custom:<地址>:<端口>引用,换机可用。其他
使用要点
bind-tcp [::]:53)或改用 853。