在教育平板、门店终端和企业手持设备中,业务常常需要执行统一的域名策略。自家 WebView 的 URL 回调只能覆盖本应用,无法处理其他使用系统 DNS 的浏览器和第三方 App。
对于设备所有者或使用者明确知情并授权的受控 Android 终端,可以用公开的 VpnService 建立一条最小链路:只把虚拟 DNS 的 /32 路由送入 TUN,解析 QNAME,先判断放行规则,再判断拦截规则;命中时返回 NXDOMAIN,其余查询仍交给设备原有的上游 DNS。
本文只实现 IPv4、UDP/53 和单 Question DNS 查询,不包含远程转发节点、HTTPS 解密或页面内容采集。系统授权页、前台服务通知、应用内停止入口和系统撤销入口必须保留。
1. 数据路径:只让 DNS 查询进入 TUN
本地接口使用 10.0.0.2,虚拟 DNS 使用 10.0.0.1。Builder 只添加 10.0.0.1/32,所以只有 DNS 查询进入 TUN;应用取得目标 IP 后,HTTPS/TCP 连接仍走默认网络。
处理过程可以压缩为六步:系统发起 DNS 查询,/32 路由把它送入 TUN,本地解析 QNAME,规则引擎完成判断,命中规则时返回 NXDOMAIN,其余查询通过受保护的 Socket 发往原网络 DNS。

路由不能写成 0.0.0.0/0,否则全部 IPv4 流量都会进入一个只会处理 DNS 的 TUN。该模块也看不到 HTTPS URL 路径,不能区分同一域名下的不同页面。
2. 系统授权与最小 TUN
服务需要声明 android.permission.BIND_VPN_SERVICE,并按 targetSdk 配置前台服务类型。授权状态只通过 VpnService.prepare() 判断;返回 Intent 时展示系统确认页,回调后再次检查授权。
建立 TUN 前先保存当前 Network 和上游 DNS。若顺序反过来,系统 DNS 已经变成 10.0.0.1,放行请求可能再次进入本地链路。
kotlin
private val requestVpn = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) {
if (VpnService.prepare(this) == null) startPolicyService()
}
fun enablePolicy() {
val consent = VpnService.prepare(this)
if (consent == null) startPolicyService() else requestVpn.launch(consent)
}
class DnsPolicyVpnService : VpnService() {
private lateinit var upstream: UpstreamDns
fun openDnsTun(): ParcelFileDescriptor? {
val cm = getSystemService(ConnectivityManager::class.java)
val network = cm.activeNetwork ?: return null
val servers = cm.getLinkProperties(network)?.dnsServers.orEmpty()
.filterNot { it.hostAddress == "10.0.0.1" }
if (servers.isEmpty()) return null
upstream = UpstreamDns(network, servers) // 必须先于 establish()
return Builder()
.setSession("Managed DNS Policy")
.addAddress("10.0.0.2", 24)
.addDnsServer("10.0.0.1")
.addRoute("10.0.0.1", 32)
.setMtu(1500)
.setBlocking(true)
.establish()
}
}
示例地址可能与企业内网冲突。生产实现应检查 LinkProperties,选择不重叠的虚拟网段;网络切换后通过 NetworkCallback 更新原始 Network 和 DNS 列表。服务启动后先调用 startForeground(),再建立 TUN。
3. 从 DNS Question 提取 hostname
TUN 返回原始 IP 包。外层解析器先确认 IPv4、UDP 和目的端口 53,再把 UDP 偏移交给 DNS 解析器。QNAME 采用"标签长度 + 标签内容"编码,读取时必须检查 Question 数量、标签长度和数组边界。
kotlin
data class DnsQuestion(val name: String, val dnsOffset: Int)
fun extractQuestion(packet: ByteArray, udpOffset: Int): DnsQuestion? {
val dnsOffset = udpOffset + 8
if (packet.size < dnsOffset + 12) return null
val flags = readU16(packet, dnsOffset + 2)
if ((flags and 0x8000) != 0 || readU16(packet, dnsOffset + 4) != 1) {
return null
}
var cursor = dnsOffset + 12
var ended = false
val labels = mutableListOf<String>()
while (cursor < packet.size) {
val length = packet[cursor++].toInt() and 0xff
if (length == 0) { ended = true; break }
if ((length and 0xc0) != 0 || length > 63) return null
if (cursor + length > packet.size) return null
labels += String(packet, cursor, length, Charsets.US_ASCII)
cursor += length
}
if (!ended || labels.isEmpty() || cursor + 4 > packet.size) return null
return DnsQuestion(labels.joinToString("."), dnsOffset)
}
private val validLabel = Regex("^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$")
fun normalizeHost(raw: String): String? {
val host = raw.trim().trimEnd('.').lowercase()
val labels = host.split('.')
return host.takeIf {
host.length in 1..253 && labels.size >= 2 &&
labels.all(validLabel::matches)
}
}
基础版本只接受未压缩、单 Question 的查询。国际化域名应在规则入口统一用 IDN.toASCII() 转为 Punycode;压缩指针、EDNS 和多 Question 应交给经过验证的 DNS 报文库处理。
4. 规则引擎:放行优先,后缀尊重标签边界
规则只保留 EXACT 和 SUFFIX 两种类型。放行规则优先,可在一个受控业务域中保留登录或更新接口。不可变快照整体替换,DNS 线程不会读到更新一半的状态。
kotlin
enum class MatchType { EXACT, SUFFIX }
data class DomainRule(val type: MatchType, val pattern: String)
data class DomainPolicy(
val denyRules: List<DomainRule>,
val allowRules: List<DomainRule> = emptyList()
)
private data class Snapshot(
val exactAllow: Set<String> = emptySet(),
val suffixAllow: Set<String> = emptySet(),
val exactDeny: Set<String> = emptySet(),
val suffixDeny: Set<String> = emptySet()
)
class DomainRuleEngine {
@Volatile private var rules = Snapshot()
fun replace(policy: DomainPolicy) {
fun pick(source: List<DomainRule>, type: MatchType) = source
.filter { it.type == type }
.mapNotNull { normalizeHost(it.pattern) }
.toSet()
rules = Snapshot(
pick(policy.allowRules, MatchType.EXACT),
pick(policy.allowRules, MatchType.SUFFIX),
pick(policy.denyRules, MatchType.EXACT),
pick(policy.denyRules, MatchType.SUFFIX)
)
}
fun shouldBlock(rawHost: String): Boolean {
val host = normalizeHost(rawHost) ?: return false
val r = rules
if (host in r.exactAllow || suffixMatch(host, r.suffixAllow)) return false
return host in r.exactDeny || suffixMatch(host, r.suffixDeny)
}
private fun suffixMatch(host: String, rules: Set<String>) =
rules.any { host == it || host.endsWith(".$it") }
}

后缀判断不能直接写成 host.endsWith(rule)。规则是 example.com 时,notexample.com 也满足普通字符串后缀,却不是它的子域。正确条件必须是完全相等,或者前面还有点号分隔的标签。
例如拦截侧配置 SUFFIX example.com,放行侧配置 EXACT login.example.com,只会保留登录域名;若放行侧也使用 SUFFIX example.com,整组拦截规则都会被覆盖。
5. 本地结果与原网络 DNS 转发
处理器先判断规则,再访问上游。规则命中时返回 NXDOMAIN;未命中时转发原始 DNS 载荷。包格式错误、网络断开和上游超时属于解析错误,不能统计成规则命中。
kotlin
fun handleDnsPacket(packet: ByteArray): ByteArray? {
val udp = packetParser.parseIpv4Udp(packet) ?: return null
if (udp.destinationPort != 53) return null
val question = extractQuestion(packet, udp.udpOffset) ?: return null
val host = normalizeHost(question.name) ?: return null
if (ruleEngine.shouldBlock(host)) {
audit(host, result = "BLOCK", reason = "RULE_MATCH")
return responseFactory.nxDomain(packet, question.dnsOffset)
}
val payload = packet.copyOfRange(question.dnsOffset, packet.size)
val answer = upstream.servers.firstNotNullOfOrNull { server ->
queryUpstream(this, upstream.network, server, payload)
} ?: return responseFactory.servFail(packet, question.dnsOffset)
audit(host, result = "ALLOW", reason = "UPSTREAM_OK")
return responseFactory.wrapUpstream(packet, answer)
}
fun queryUpstream(
vpn: VpnService,
network: Network,
server: InetAddress,
payload: ByteArray
): ByteArray? = try {
DatagramSocket().use { socket ->
socket.soTimeout = 800
if (!vpn.protect(socket)) return@use null
network.bindSocket(socket)
socket.send(DatagramPacket(payload, payload.size, server, 53))
val buffer = ByteArray(4096)
val response = DatagramPacket(buffer, buffer.size)
socket.receive(response)
buffer.copyOf(response.length)
}
} catch (_: IOException) {
null
}
NXDOMAIN 表示域名不存在,SERVFAIL 表示解析器暂时无法完成请求。日志应分别记录 BLOCK、ALLOW 和 ERROR。放行路径必须先调用 protect(),否则本地处理器可能再次收到自己发出的查询。
responseFactory 需要保留 Transaction ID 和 Question,翻转源/目的 IP、UDP 端口,并重新计算 IPv4 头校验和。若扩展到 IPv6,UDP 校验和是必需项。
6. 热更新:替换快照并清理缓存
更新规则不需要重建 TUN。先把新规则写入数据库或版本化文件,再通知服务整体替换快照,并同时清理规则缓存和 DNS 应答缓存。
kotlin
private fun observePolicy() = serviceScope.launch {
policyRepository.policies.collect { versioned ->
ruleEngine.replace(versioned.policy)
ruleMatchCache.clear()
dnsResponseCache.clear()
statusStore.updateRuleVersion(versioned.version)
}
}
大规则集不要放入 Intent extras,Binder 事务存在大小限制。DNS 缓存键至少包含标准化域名、QTYPE 和 QCLASS;如果缓存原始载荷,返回前还要替换 Transaction ID。
"立即生效"指下一条进入模块的新 DNS 查询使用新规则。浏览器、系统解析器、业务进程和已建立的 TCP 连接可能仍保留旧结果,策略模块不会主动终止已有连接。
7. 验收与能力边界
只看浏览器错误页无法证明规则命中,验收应同时检查 DNS 结果、命中原因和上游状态。
| 场景 | 条件 | 预期结果 |
|---|---|---|
| 精确匹配 | EXACT video.example.com |
仅该域名返回 NXDOMAIN |
| 后缀匹配 | SUFFIX example.com |
根域和多级子域均命中 |
| 相似字符串 | notexample.com |
正常转发 |
| 放行覆盖 | 放行 EXACT login.example.com |
登录域名放行,其余仍命中 |
| 热更新 | 删除命中规则 | 下一条新查询恢复转发 |
| 上游断开 | 无规则命中 | 返回 SERVFAIL,记录为 ERROR |
| 用户撤销授权 | 任意规则 | 服务停止并显示未授权 |
上线前还要明确五个边界:本文只处理 IPv4 UDP/53;App 自带的 DoH/DoT 不会进入本模块;直接 IP 请求没有 hostname;已经建立的连接不会因新增规则自动断开;Android 同一用户空间通常只有一个当前 VPN 槽位。
Device Owner 可以通过 Always-on 和 Lockdown 加强持续启用,但不会改变域名解析、规则优先级和上游转发逻辑。进程恢复、开机启动和定制系统集成不在这条最小实现主线中展开。
经用户授权后,把虚拟 DNS 的
/32路由接入 TUN;解析并归一化 QNAME,先判断放行规则,再判断拦截规则;命中时返回 NXDOMAIN,其余查询通过protect()后的 Socket 使用设备原有上游 DNS。
参考资料
- Android Developers:VpnService
- Android Developers:VPN developer's guide
- Android Developers:LinkProperties
- Android Developers:Foreground service types
- RFC 1035:Domain Names - Implementation and Specification
本文代码由真实 Android 受控终端项目中的系统授权、本地 DNS 处理、规则匹配和热更新流程抽象、重构并脱敏,用于说明通用工程方法,不等同于任何生产项目源码。示例只处理经设备所有者或使用者明确授权的合法设备管理场景。