虽然大家一致认为Java不适合做底层,但我觉得不适合不代表不能做。
本篇文章比起札记,其实更像是一篇show off。
背景
最近我在看去中心系统相关的内容,看到了Yggdrasil,且不说它的原理如何,它在使用方式上就让我耳目一新。传统的overlay network都需要通过某种应用层的接口来让用户访问,比如Tor和I2P都需要浏览器支持,后者更是提供了诸如socks这种代理协议来提供访问,这就要求应用程序必须专门考虑并为此设计,才能获得一定程度的兼容性。对于不支持socks协议的程序,很遗憾,它没法接入I2P网络。
Yggdrasil不同:它在运行时创建了一个TUN设备。TUN设备是一种虚拟网络适配器,类似TAP。TAP设备工作在二层,换言之它能够得到的是数据链路层的数据包(更细致地说,其实是媒体访问控制MAC包),而TUN设备则工作在三层,它能够得到的数据是IP包。Yggdrasil将网络中的每一个节点的公钥映射成IPv6地址中被废弃的一段0020::0/7
,这段地址在可观的未来不会被使用(但Yggdrasil网络中节点的公钥是256位,而这个地址段只有121位,我还不太清楚他们是怎么解决地址冲突的),每个节点分配一个段内的IPv6地址,这样一来,只要在操作系统中访问这个段内的地址,不管你是ICMP还是TCP还是UDP,操作系统都封装成IP包,交给这个TUN设备。而Yggdrasil从这个TUN设备中获取封装好的IP号,看一下目的地地址,在网络内路由,到达对方节点后,直接通过TUN设备写入操作系统的IP栈,之后无论是什么协议,全部由操作系统负责处理和分发。多么优雅的解决方案啊!
但是只有一个遗憾,这个软件是Go写的,而我是写Java的。我翻遍了Google和GitHub,也没有发现支持Java在Windows上创建和操作TUN设备的库。我很不爽,于是就自己写了一个。
自己造轮子
经过一番搜索,我发现了一个叫做wintun的C++库,这个库将win32 API的调用封装成了简单的函数,诸如WintunCreateAdapter
这种。要知道,让Java调用C就已经很困难了,你需要处理好Java对象和C结构体之间的映射,要处理好指针、类型的问题。而微软的Win32为了让事情变得更加复杂(其实是历史遗留问题),他们使用了自己的一套类型系统,并且有者自己的一套API设计理念,用起来更加麻烦。
看到有前人栽树,现在后人就得想怎么乘凉。好在Java调用C并不是什么稀奇事情:除了原生的JNI之外,我们还有JNA和JNR,还有未来的Project Panama。就目前而言,对于这个项目来讲,JNA是最好的选择。关于编写mapping的过程就不再赘述了,无非就是参照JNA文档,遇到问题了Google一下。不过其中有个小插曲,就是无论怎么调试,一开始得到的结果返回的都是null,我百思不得其解啊。最后无意中想到,这个玩意儿是不是需要管理员权限?但是IDEA并没有提供单独将程序作为管理员运行的选项,不得已,我只能将整个IDEA作为管理员运行。对于来路不明的软件,这个动作其实非常危险:不光是IDEA,由IDEA启动的Gradle也是作为管理员运行的,如果构建脚本里有一些恶意代码,使用管理员权限运行刚好给了他们可乘之机。不过考虑到这个是我自己写的软件,顶多蓝屏,能怎么样?
编写好了Mapping,我发现一个问题:这个软件只能创建和操作TUN设备,但是没办法给TUN设备分配IP。虽然我可以在TUN设备中模拟DHCP包,但是这未免有些复杂且太不灵活了。查阅了一大堆文档,最后发现win32 API还是绕不开了。于是开始想办法编写相关的win32 API。具体内容可以参考这个项目的GitHub Repo。
因为是C,免不了很多面向过程编程。与其一开始考虑怎么把这个东西做的尽可能OOP,我的建议是每次针对一个功能(例如列出IP,插入MIB ROW等),写一个demo,确保能用之后,再将这个功能封装成对象的成员方法。不然一边设计对象,一边考虑C的过程,实属给自己找不痛快。
展示
经过了一周的艰苦奋斗,这个库终于是成了。为了验证效果,我借助pcap4j实现了ICMP ECHO(也就是ping)响应。效果就是,运行程序,创建一个TUN设备,程序为这个TUN设备分配IP为0020::100/7
,并开始处理IP包。这个时候在系统内ping任意一个同网段的ip,都可以得到相对应的ping响应。
总体流程还是简单明了的:
package info.skyblond.jna
import com.sun.jna.platform.win32.Guid
import info.skyblond.jna.wintun.*
import org.pcap4j.packet.*
import org.pcap4j.packet.namednumber.IcmpV6Code
import org.pcap4j.packet.namednumber.IcmpV6Type
import org.pcap4j.packet.namednumber.IpNumber
import org.pcap4j.packet.namednumber.IpVersion
import java.io.EOFException
import java.net.Inet6Address
import kotlin.concurrent.thread
import kotlin.experimental.and
import kotlin.random.Random
fun main(args: Array<String>) {
println("Current wintun version: ${WintunLib.INSTANCE.WintunGetRunningDriverVersion()}")
val guid = Guid.GUID.newGuid().toGuidString()
val adapter = WintunAdapter("Wintun Demo Adapter", "Wintun", guid)
// Ring size: 8MB
val session = adapter.newSession(0x800000)
try { // clean up old ip
adapter.dissociateIp(ip)
} catch (t: Throwable) {
t.printStackTrace()
}
val ip = Inet6Address.getByName("0020::100")
println("Set ip to: $ip")
adapter.associateIp(
AdapterIPAddress(ip = ip, prefixLength = 7u)
)
TODO("Handle IP packets")
println("Closing!")
session.close()
adapter.close()
}
整体流程就是创建网卡、启动Session、设定IP、处理IP包,最后关闭资源。
处理IP包的部分可以单独放在一个线程里:
val t = thread {
try {
while (true) {
val result = session.readPacket()
if (result != null) thread { handlePacket(session, result) }
}
} catch (e: EOFException) {
e.printStackTrace()
} catch (e: NativeException) {
e.printStackTrace()
}
}
while (t.isAlive) {
Thread.sleep(1000)
}
由于读取的时候不一定总会读出数据,因此需要判断一下读出的内容是否为空,不为空再处理。处理起来也相对简单,无非是解析包,拿到目的地IP,然后创建一个回复的包,写回操作系统:
private fun handlePacket(session: WintunSession, packet: ByteArray) {
val isV6 = packet[0].and(0xf0.toByte()) == 0x60.toByte()
println(
"Get IPv${if (isV6) "6" else "4"} packet from OS\n" +
"\tSize: ${packet.size} bytes\n"
)
if (isV6) {
val v6Packet = IpV6Packet.newPacket(packet, 0, packet.size)
println(v6Packet)
(v6Packet.payload as? IcmpV6CommonPacket)?.let { icmpV6Common ->
(icmpV6Common.payload as? IcmpV6EchoRequestPacket)?.let { request ->
val reply = IpV6Packet.Builder()
.version(IpVersion.IPV6)
.trafficClass(IpV6SimpleTrafficClass.newInstance(0x00))
.flowLabel(IpV6SimpleFlowLabel.newInstance(0))
.srcAddr(v6Packet.header.dstAddr)
.dstAddr(v6Packet.header.srcAddr)
.nextHeader(IpNumber.ICMPV6)
.hopLimit(127)
.correctLengthAtBuild(true)
.payloadBuilder(
IcmpV6CommonPacket.Builder()
.srcAddr(v6Packet.header.dstAddr)
.dstAddr(v6Packet.header.srcAddr)
.type(IcmpV6Type.ECHO_REPLY)
.code(IcmpV6Code.NO_CODE)
.correctChecksumAtBuild(true)
.payloadBuilder(
IcmpV6EchoReplyPacket.Builder()
.identifier(request.header.identifier)
.sequenceNumber(request.header.sequenceNumber)
.payloadBuilder(request.payload.builder)
)
)
.build()
println("Reply: \n$reply")
session.writePacket(reply.rawData)
}
}
} else {
val v4Packet = IpV4Packet.newPacket(packet, 0, packet.size)
println(v4Packet)
}
}
程序运行起来之后ping 0020::300
,效果如下:
关于完整的程序和这个库,可以在GitHub上找到。
-全文完-
【代码札记】使用JNA在Windows上创建并操作TUN设备 由 天空 Blond 采用 知识共享 署名 - 非商业性使用 - 相同方式共享 4.0 国际 许可协议进行许可。
本许可协议授权之外的使用权限可以从 https://skyblond.info/about.html 处获得。