Android 设备管控开发实战:企业受控终端的 DNS 域名策略实现

在教育平板、门店终端和企业手持设备中,业务常常需要执行统一的域名策略。自家 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. 规则引擎:放行优先,后缀尊重标签边界

规则只保留 EXACTSUFFIX 两种类型。放行规则优先,可在一个受控业务域中保留登录或更新接口。不可变快照整体替换,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 表示解析器暂时无法完成请求。日志应分别记录 BLOCKALLOWERROR。放行路径必须先调用 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。

参考资料

  1. Android Developers:VpnService
  2. Android Developers:VPN developer's guide
  3. Android Developers:LinkProperties
  4. Android Developers:Foreground service types
  5. RFC 1035:Domain Names - Implementation and Specification

本文代码由真实 Android 受控终端项目中的系统授权、本地 DNS 处理、规则匹配和热更新流程抽象、重构并脱敏,用于说明通用工程方法,不等同于任何生产项目源码。示例只处理经设备所有者或使用者明确授权的合法设备管理场景。

相关推荐
OH_TPC2 小时前
【鸿蒙优选三方库】@ohos/grpc:在 HarmonyOS 上像调本地方法一样调远程服务
华为·harmonyos·鸿蒙
达子6662 小时前
第30章_综合案例:HarmonyOs开发图解之 俄罗斯方块游戏
游戏·华为·harmonyos
云端漫步19872 小时前
HarmonyOS NEXT AI 应用开发总结:30 篇之旅
人工智能·华为·harmonyos
c238563 小时前
MySQL 基础用法(下):查询进阶与核心特性
android·c语言·c++·mysql
math_hongfan3 小时前
鸿蒙Agent高级技能自定义开发:自定义意图服务/能力注册/Agent技能编排/服务插件开发实战
android·学习·华为·harmonyos·鸿蒙
2501_915106324 小时前
iOS 证书类型及作用说明 账号证书与签名配置的完整解读
android·ios·小程序·https·uni-app·iphone·webview
九皇叔叔4 小时前
RHEL9 安装 MySQL 8.4.11.LTS 版本
android·mysql·adb
海兰4 小时前
【数据库】tdsql(MySQL )的事务隔离级别
android·数据库·mysql
哆啦A梦z5 小时前
Android BLE 实战:从小米手环 7 踩坑到完整连接框架
android