ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

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

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放行请求可能再次进入本地链路。privatevalrequestVpnregisterForActivityResult(ActivityResultContracts.StartActivityForResult()){if(VpnService.prepare(this)null)startPolicyService()}funenablePolicy(){valconsentVpnService.prepare(this)if(consentnull)startPolicyService()elserequestVpn.launch(consent)}classDnsPolicyVpnService:VpnService(){privatelateinitvarupstream:UpstreamDnsfunopenDnsTun():ParcelFileDescriptor?{valcmgetSystemService(ConnectivityManager::class.java)valnetworkcm.activeNetwork?:returnnullvalserverscm.getLinkProperties(network)?.dnsServers.orEmpty().filterNot{it.hostAddress10.0.0.1}if(servers.isEmpty())returnnullupstreamUpstreamDns(network,servers)// 必须先于 establish()returnBuilder().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 提取 hostnameTUN 返回原始 IP 包。外层解析器先确认 IPv4、UDP 和目的端口 53再把 UDP 偏移交给 DNS 解析器。QNAME 采用“标签长度 标签内容”编码读取时必须检查 Question 数量、标签长度和数组边界。dataclassDnsQuestion(valname:String,valdnsOffset:Int)funextractQuestion(packet:ByteArray,udpOffset:Int):DnsQuestion?{valdnsOffsetudpOffset8if(packet.sizednsOffset12)returnnullvalflagsreadU16(packet,dnsOffset2)if((flagsand0x8000)!0||readU16(packet,dnsOffset4)!1){returnnull}varcursordnsOffset12varendedfalsevallabelsmutableListOfString()while(cursorpacket.size){vallengthpacket[cursor].toInt()and0xffif(length0){endedtrue;break}if((lengthand0xc0)!0||length63)returnnullif(cursorlengthpacket.size)returnnulllabelsString(packet,cursor,length,Charsets.US_ASCII)cursorlength}if(!ended||labels.isEmpty()||cursor4packet.size)returnnullreturnDnsQuestion(labels.joinToString(.),dnsOffset)}privatevalvalidLabelRegex(^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$)funnormalizeHost(raw:String):String?{valhostraw.trim().trimEnd(.).lowercase()vallabelshost.split(.)returnhost.takeIf{host.lengthin1..253labels.size2labels.all(validLabel::matches)}}基础版本只接受未压缩、单 Question 的查询。国际化域名应在规则入口统一用IDN.toASCII()转为 Punycode压缩指针、EDNS 和多 Question 应交给经过验证的 DNS 报文库处理。4. 规则引擎放行优先后缀尊重标签边界规则只保留EXACT和SUFFIX两种类型。放行规则优先可在一个受控业务域中保留登录或更新接口。不可变快照整体替换DNS 线程不会读到更新一半的状态。enumclassMatchType{EXACT,SUFFIX}dataclassDomainRule(valtype:MatchType,valpattern:String)dataclassDomainPolicy(valdenyRules:ListDomainRule,valallowRules:ListDomainRuleemptyList())privatedataclassSnapshot(valexactAllow:SetStringemptySet(),valsuffixAllow:SetStringemptySet(),valexactDeny:SetStringemptySet(),valsuffixDeny:SetStringemptySet())classDomainRuleEngine{VolatileprivatevarrulesSnapshot()funreplace(policy:DomainPolicy){funpick(source:ListDomainRule,type:MatchType)source.filter{it.typetype}.mapNotNull{normalizeHost(it.pattern)}.toSet()rulesSnapshot(pick(policy.allowRules,MatchType.EXACT),pick(policy.allowRules,MatchType.SUFFIX),pick(policy.denyRules,MatchType.EXACT),pick(policy.denyRules,MatchType.SUFFIX))}funshouldBlock(rawHost:String):Boolean{valhostnormalizeHost(rawHost)?:returnfalsevalrrulesif(hostinr.exactAllow||suffixMatch(host,r.suffixAllow))returnfalsereturnhostinr.exactDeny||suffixMatch(host,r.suffixDeny)}privatefunsuffixMatch(host:String,rules:SetString)rules.any{hostit||host.endsWith(.$it)}}后缀判断不能直接写成host.endsWith(rule)。规则是example.com时notexample.com也满足普通字符串后缀却不是它的子域。正确条件必须是完全相等或者前面还有点号分隔的标签。例如拦截侧配置SUFFIX example.com放行侧配置EXACT login.example.com只会保留登录域名若放行侧也使用SUFFIX example.com整组拦截规则都会被覆盖。5. 本地结果与原网络 DNS 转发处理器先判断规则再访问上游。规则命中时返回NXDOMAIN未命中时转发原始 DNS 载荷。包格式错误、网络断开和上游超时属于解析错误不能统计成规则命中。funhandleDnsPacket(packet:ByteArray):ByteArray?{valudppacketParser.parseIpv4Udp(packet)?:returnnullif(udp.destinationPort!53)returnnullvalquestionextractQuestion(packet,udp.udpOffset)?:returnnullvalhostnormalizeHost(question.name)?:returnnullif(ruleEngine.shouldBlock(host)){audit(host,resultBLOCK,reasonRULE_MATCH)returnresponseFactory.nxDomain(packet,question.dnsOffset)}valpayloadpacket.copyOfRange(question.dnsOffset,packet.size)valanswerupstream.servers.firstNotNullOfOrNull{server-queryUpstream(this,upstream.network,server,payload)}?:returnresponseFactory.servFail(packet,question.dnsOffset)audit(host,resultALLOW,reasonUPSTREAM_OK)returnresponseFactory.wrapUpstream(packet,answer)}funqueryUpstream(vpn:VpnService,network:Network,server:InetAddress,payload:ByteArray):ByteArray?try{DatagramSocket().use{socket-socket.soTimeout800if(!vpn.protect(socket))returnusenullnetwork.bindSocket(socket)socket.send(DatagramPacket(payload,payload.size,server,53))valbufferByteArray(4096)valresponseDatagramPacket(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 头校验和。若扩展到 IPv6UDP 校验和是必需项。6. 热更新替换快照并清理缓存更新规则不需要重建 TUN。先把新规则写入数据库或版本化文件再通知服务整体替换快照并同时清理规则缓存和 DNS 应答缓存。privatefunobservePolicy()serviceScope.launch{policyRepository.policies.collect{versioned-ruleEngine.replace(versioned.policy)ruleMatchCache.clear()dnsResponseCache.clear()statusStore.updateRuleVersion(versioned.version)}}大规则集不要放入 Intent extrasBinder 事务存在大小限制。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/53App 自带的 DoH/DoT 不会进入本模块直接 IP 请求没有 hostname已经建立的连接不会因新增规则自动断开Android 同一用户空间通常只有一个当前 VPN 槽位。Device Owner 可以通过 Always-on 和 Lockdown 加强持续启用但不会改变域名解析、规则优先级和上游转发逻辑。进程恢复、开机启动和定制系统集成不在这条最小实现主线中展开。经用户授权后把虚拟 DNS 的/32路由接入 TUN解析并归一化 QNAME先判断放行规则再判断拦截规则命中时返回 NXDOMAIN其余查询通过protect()后的 Socket 使用设备原有上游 DNS。参考资料Android DevelopersVpnServiceAndroid DevelopersVPN developer’s guideAndroid DevelopersLinkPropertiesAndroid DevelopersForeground service typesRFC 1035Domain Names - Implementation and Specification本文代码由真实 Android 受控终端项目中的系统授权、本地 DNS 处理、规则匹配和热更新流程抽象、重构并脱敏用于说明通用工程方法不等同于任何生产项目源码。示例只处理经设备所有者或使用者明确授权的合法设备管理场景。
返回列表