开始之前先叠个甲:这篇博文仅供技术交流和娱乐(标题里也说了是在整烂活了),文中提到的以及你能想到的应用场景实际上都有更加成熟的解决方案,部分操作也不一定符合部分工作单位的操作规范,甚至可能存在相当大的安全风险。所以把这篇博文当个乐子看就行啦,如果能对你有一定的启发那就更好了。
我不是专业的网络工程师,也不是专业的 Linux 开发者或者运维,更不是专业的 USB 和嵌入式开发者。如果这篇博文的遣词造句或者执行的操作有不恰当的地方,请在评论区中帮我指正。
我在之前的 《在 WSL 2 中连接 USB 存储设备》 这篇博文中提到过 USB/IP——简单来说,就是一套将 USB 数据包封装进 TCP 数据包,通过 IP 网络进行传输,从而实现远程连接 USB 设备的方案。
最近和群友聊天的时候又一次提到了 USB/IP,出于好奇心就去多了解了一下,然后发现 USB/IP 项目的官网上赫然写着:
A computer can use remote USB devices as if they were directly attached; for example, we can …
- …
- USB printers, USB scanners, USB serial converters and USB Ethernet interfaces: ok, work fine.
WTF?你是说,USB/IP 作为一个依赖网络的、通过网络传输数据的协议,还能倒反天罡,用来传输 USB 网卡这种网络设备的数据包?
既然这样的话,我们是不是可以把 USB 网卡通过 USB/IP 透传出去,再挂载到远端主机上,实现异地组网呢?
安全提示
这里要再叠个甲:USB/IP 是没有任何加密和认证机制的,或者说其本身就不是为了公网和不受信任的网络环境设计的。所有直接经过公网传输的 USB/IP 数据包都可以被抓包和中间人攻击,所有能接触到你的 USB/IP 服务端的人都可以直接挂载你导出的 USB 设备。所以在公网环境下直接使用裸的 USB/IP 是非常危险的,如果你想在公网环境下使用 USB/IP,一定要通过 SSH 隧道或者 VPN 等加密通道来保护 USB/IP 的数据传输安全。
此外,操作远端设备的网络接口的行为本身就是极具风险的。一旦操作不慎,就有可能导致设备无法联网,从而失去对设备的远程访问能力。所以请务必确保你拥有至少一种能够访问你所操作的设备的备份连接手段,比如云计算服务商提供的 VNC 或者串口控制台,最次最次也至少应该能让你在无法连接到远端设备时对远端设备执行强制重启操作。
YOU’VE BEEN WARNED.
环境准备
这篇博文主要以 Linux 为例进行说明,具体到发行版的话是 Ubuntu Server 24.04 LTS。
USB/IP 在 Linux 3.17 之后就已经合并入内核主线了,大部分现代的 Linux 发行版应该都支持 USB/IP,只不过大多数情况下都是以内核模块的形式存在的,需要执行以下指令手动加载:
1 | sudo modprobe usbip_core # USB/IP 的核心模块 |
如果你在加载内核模块时遇到了 FATAL: Module usbip_core not found 这样的错误,一般是你的发行版自带的内核把 USB/IP 相关的模块裁剪掉了(一般来说是云计算服务商在为自家虚拟机平台优化内核时干的,比如 Azure)。你可能需要安装内核模块扩展包 linux-modules-extra,或者换用 linux-image-generic 之类的通用内核,或者自己手动编译一个内核,或者通过 DKMS 等方式加载这几个模块。
1 | # 安装内核模块扩展包 |
部分发行版的内核可能有编译 USB/IP 的内核模块,但是裁剪掉了 USB/IP 的用户空间工具。不过这不是什么大问题,安装 linux-tools 即可补全:
1 | sudo apt install linux-tools-$(uname -r) # Ubuntu / Debian |
Windows 没有自带 USB/IP 支持,但是有第三方的实现。USB/IP 服务端可以用 dorssel/usbipd-win,在操作上与 Linux 上的 usbipd 大同小异;客户端可以用 vadimgrn/usbip-win2,其带有一个图形化的前端,使用起来相当方便。
至于 macOS,我简单搜索了一下,除了一个使用私有协议的商业软件 VirtualHere 以外,好像并没有什么成熟的 USB/IP 实现,残念です。
正着用
正常的用法相当简单直白:将 USB 网卡接入已联网的本地主机,并用网线将其与路由器 / 交换机连接;本地主机通过 USB/IP 将 USB 网卡远程透传出去,远端主机挂载上之后就能直接获取本地内网 IP,实现对内网资源的无缝访问。
本地主机这边,接好 USB 网卡、加载好内核模块后,执行以下指令查看 USB 设备列表:
1 | sudo usbip list -l |
你应该能看到类似这样的输出:
1 | # 这里列出的是我的 Realtek RTL8153 USB 网卡 |
记下要导出的 USB 网卡的 busid,然后执行以下指令将其绑定到 USB/IP 主机驱动上:
1 | sudo usbip bind -b 2-1 # 替换成你自己的 busid |
绑定之后,这张 USB 网卡就被 USB/IP 服务端的主机驱动接管了,不能再被本地主机使用了。
接下来,在后台启动 USB/IP 服务端:
1 | sudo usbipd -D |
USB/IP 服务端会监听 TCP 3240 端口,也可以通过 -t 参数指定其他端口。不过我试了一下,Linux 上的 USB/IP 客户端在连接服务端时似乎会忽略 -t 参数,只能连接 3240 端口。
现在这张 USB 网卡就可以被远端主机挂载了。在远端主机上加载完好内核模块后,执行以下指令列出 USB/IP 服务器上可用的 USB 设备列表:
1 | sudo usbip list -r <USB_IP_SERVER> # 替换成你的 USB/IP 服务器的地址 |
你应该能看到这样的输出:
1 | Exportable USB devices |
记下要挂载的 USB 网卡的 busid,然后执行以下指令将其挂载到本地:
1 | sudo usbip attach -r <USB_IP_SERVER> -b 2-1 # 替换成你的 USB/IP 服务器的地址和 busid |
挂载完成后,稍等大约半分钟,等系统识别到设备并加载好驱动后,应该就能在 lsusb 的输出里看到这张 USB 网卡了:
1 | Bus 001 Device 001: ID 1d6b:0002 Linux Foundation 2.0 root hub |
ip a 的输出里应该也能看到多出了一个网络接口:
1 | # 网络接口的名字可能不一样,我这里是 eth1 |
但是目前这个网络接口是 DOWN 的,也没有配置 IP 地址和路由表,还不能直接使用。在 Ubuntu Server 24.04 LTS 上,我们可以通过 Netplan 来配置这个网络接口。在 /etc/netplan/ 目录下新建一个 Netplan 配置文件 99-usbip-nic.yaml:
1 | network: |
执行以下指令生成网络配置管理器的配置文件:
1 | sudo netplan generate |
让网络配置管理器重载配置文件:
1 | sudo networkctl reload |
然后拉起网络接口:
1 | sudo ip link set eth1 up # 记得把 eth1 改成你的网络接口的名称 |
此时再次查看 ip a 的输出,应该就能看到 eth1 已经成功获取到了 IP 地址:
1 | 2: eth1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq state UP group default qlen 1000 |
接下来就可以愉快地用内网 IP 访问内网资源啦!
1 | curl -i http://192.168.1.1 |
如果要在远端主机上卸载 USB 网卡,需要先执行以下指令,查看当前挂载的 USB 设备列表:
1 | sudo usbip port |
输出应该是这样的:
1 | Imported USB devices |
记下要卸载的 USB 网卡对应的 Port,然后执行以下指令卸载 USB 网卡:
1 | sudo usbip detach -p 15 # 替换成你自己的 Port |
此时再查看 lsusb 和 ip a 的输出,应该就看不到这张 USB 网卡和对应的网络接口了。
如果不出意外的话,下次要再次挂载的时候,只需要再次 usbip attach 即可,不需要再次修改 Netplan 和网络配置管理器的配置文件了,挂载完成后稍等一会儿即可获取到 IP 地址。
如果要恢复 USB 网卡在本地主机的使用,在远端主机上卸载 USB 网卡后,在本地主机上执行以下指令解绑即可:
1 | sudo usbip unbind -b 2-1 # 替换成你自己的 busid |
反着用
没想到吧,这篇博文还是个正反手教学(笑。
有时候,我们需要反过来,在家中访问远端机房的内网资源。但机房中的服务器也不是每台都有暴露到公网的,一些资源需要通过跳板机才能访问,如果需要访问的资源比较多的话,一个个配置端口转发相当麻烦。(不过感觉已经有点没事找事了)
但我们不是 奥高工,没办法单摸机房爆改服务器。在这种情况下,有没有什么方案,可以让我们直接使用机房的内网 IP 访问内网资源呢?
有的兄弟,有的。既然 USB/IP 能够在主机上虚拟出一张 USB 网卡,让系统识别到并且通过这张虚拟网卡正常联网,那么我们能不能反向操作,自己虚拟出一张 USB 网卡出来,让 USB/IP 导出呢?
如果你在互联网上搜索「Linux 虚拟 USB 设备」,最后大概率会找到 dummy_hcd 这个模块。其在 Linux 2.6.20 加入内核主线,可以创建一个或者多个虚拟的 USB 主机控制器(HCD)和 USB 设备控制器(UDC)。再通过 libcomposite 模块,我们就可以在主机上创建一个虚拟的 USB Gadget,绑定到 dummy_hcd 提供的虚拟 UDC 上,再挂载到虚拟 HCD 上,最后通过 USB/IP 导出。
但实际上,就我们的应用场景以及现代的 Linux 发行版来说,dummy_hcd 还是太麻烦了。USB/IP 在 Linux 4.7 新增了一个内核模块:usbip_vudc,其可以在本地主机上创建一个或多个直通 USB/IP 服务端的虚拟的 USB UDC,绑定 libcomposite 模块创建的虚拟 USB Gadget 后就可以直接将其通过 USB/IP 导出,而无需任何 HCD 参与其中。
在远端主机上加载 usbip_vudc 内核模块:
1 | sudo modprobe usbip_vudc num=1 # 要创建几个虚拟 UDC,num 参数的值就设为几 |
此时应该就能在 /sys/class/udc/ 下看到 usbip_vudc 创建的虚拟 UDC 了:
1 | ls /sys/class/udc/ |
现在虚拟的 USB UDC 已经有了,但是我们要怎么定义这个 USB 设备,让其变成一张 USB 网卡呢?
与其去逆向各家 USB 网卡的专有驱动,这里我选择的方案是 USB CDC-NCM,也就是 Android 12 之后的 USB 网络共享方案(不过大部分国产手机厂商都还在用 RNDIS,微软的专有协议)。近年来的操作系统基本都有支持 CDC-NCM,兼容性应该还是 OK 的。
在远端主机上加载 libcomposite 和 CDC-NCM 功能组件内核模块:
1 | sudo modprobe libcomposite |
创建 USB Gadget 实例:
1 | # /sys/kernel/config 目录是 ConfigFS 的默认挂载点 |
只需要创建这个目录,ConfigFS 和 libcomposite 就会在该目录下生成一堆用来配置 Gadget 属性的接口:
1 | # 还记得吗?Linux 的哲学是「一切皆文件」 |
进入刚刚创建的 USB Gadget 实例目录,写入设备标识符:
1 | cd /sys/kernel/config/usb_gadget/usbip_ncm |
然后在 USB Gadget 中创建 CDC-NCM 模块实例:
1 | sudo mkdir -p functions/ncm.usb0 |
同样地,目录创建后会生成一堆接口:
1 | ls functions/ncm.usb0 |
其中 dev_addr 和 host_addr 分别是 USB 设备端(也就是 USB/IP 服务端)和 USB 主机端(也就是 USB/IP 客户端)的网络接口的 MAC 地址。内核会直接在这两个接口中随机生成 MAC 地址,但是这里有个坑:如果不手动写入一次 MAC 地址的话,后续 USB 设备端的 CDC-NCM 网络接口的 MAC 地址会变成系统随机生成的另一个 MAC 地址。所以我们必须手动写入一次 MAC 地址,哪怕只是读取后再原样写入:
1 | # 当然,你也可以写入自定义的 MAC 地址 |
为什么会有两个 MAC 地址?
因为 CDC-NCM 实际上是将两端都当成独立的网络节点,在两端都创建了一个虚拟网络接口,再在网络接口之间建立了一个点对点的以太网连接,相当于两个虚拟网络接口直接通过一条网线连接在一起。
想象一下 Android 手机通过 USB 共享网络的场景就很好理解了。在这个场景下,手机实际上承担起了路由器和网关的作用,电脑(USB 主机端)需要先将数据包发送给手机,手机(USB 设备端)再将数据包转发到公网。所以手机这边需要一个网络接口和一个 MAC 地址,电脑这边也需要一个网络接口和一个 MAC 地址。
为 Gadget 创建 USB 配置:
1 | sudo mkdir -p configs/c.1/strings/0x409 # 同上,0x409 是美式英语的语言 ID |
将 USB Gadget 绑定到 usbip_vudc 创建的虚拟 UDC 上:
1 | echo usbip-vudc.0 | sudo tee UDC |
稍等半分钟,ip a 的输出中应该就能看到 CDC-NCM 的 USB 设备端网络接口了:
1 | 2: usb0: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN group default qlen 1000 |
尝试拉起网络接口:
1 | sudo ip link set usb0 up |
此时再次执行 ip a,会发现网络接口还是 DOWN 的,但是比刚才多了一个 NO-CARRIER 的 Flag:
1 | 2: usb0: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc fq_codel state DOWN group default qlen 1000 |
这是因为本地主机还没有挂载上 CDC-NCM 的 USB 主机端网络接口,相当于 USB 线只插了一头,所以没有载波信号。
接下来,启动 USB/IP 服务端。这次加上 -e 参数,让其读取虚拟 UDC 上的 USB Gadget:
1 | sudo usbipd -e -D |
然后来到本地主机,列出远端主机的 USB/IP 服务端上的可用设备:
1 | sudo usbip list -r <USB_IP_SERVER> # 替换成你的 USB/IP 服务器的地址 |
你应该能看到类似这样的输出:
1 | Exportable USB devices |
看!这里的 busid 变成了 usbip-vudc.0,也就是我们 usbip_vudc 创建的虚拟 UDC,设备制造商和设备类型也和我们刚刚写入 idVendor 和 idProduct 接口的一致。
将 CDC-NCM 的 USB 主机端网络接口挂载到本地:
1 | sudo usbip attach -r <USB_IP_SERVER> -b usbip-vudc.0 |
稍等一会儿,等系统识别到 Gadget 并加载好驱动后,应该就能在 lsusb 的输出里看到我们的 USB Gadget 了:
1 | Bus 001 Device 001: ID 1d6b:0002 Linux Foundation 2.0 root hub |
ip a 的输出中也能看到多了一个网络接口:
1 | 3: usb0: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN group default qlen 1000 |
拉起这个网络接口:
1 | sudo ip link set usb0 up |
此时查看两端的 ip a 的输出,会发现网络接口的状态都变成了 UP:
1 | # 远端主机,USB 设备端 |
这样两边的 USB CDC-NCM 网络接口就连接起来了,但是还没有配置 IP 地址和路由表,所以还不能直接使用。(其实上面看到的 IPv6 的 Link-Local 地址是能通的,但是用 IPv6 的内网环境目前感觉还是比较少见,要配置 NAT64 的话就很麻烦了)
根据远端主机所在网络环境的情况,配置 IP 地址的方式也有所不同。条件允许的情况下(比如家庭局域网),直接将 USB 设备端的 CDC-NCM 网络接口桥接到已有的网络接口上是最方便的,这样就相当于把网络接口直接插在了路由器上,就和上面「正着用」的情况一样了,最多就是再配置一下静态 IP 的事。但是就我们的应用场景来说,机房内网(包括各大云计算服务商提供的虚拟私有网络)比家庭局域网复杂太多了,直接桥接大概率是行不通的,必须使用 NAT。
先来配置远端主机的 IP 地址,在 /etc/netplan/ 目录下新建一个 Netplan 配置文件 70-usbip-ncm-device.yaml,写入如下内容,设置 USB 设备端的网络接口的静态 IP:
1 | network: |
然后在本地主机上的相同位置新建一个 Netplan 配置文件 70-usbip-ncm-host.yaml,写入以下内容,设置 USB 主机端的网络接口的静态 IP:
1 | network: |
然后在两端都生成并重载网络配置管理器的配置文件:
1 | sudo netplan generate |
这个时候查看 ip a,应该就能看到两边的 USB CDC-NCM 网络接口都配置好静态 IP 地址了:
1 | # 远端主机,USB 设备端 |
然后两端就能互相访问了:
1 | # 本地主机访问远端主机 |
现在来配置 NAT。Ubuntu Server 24.04 LTS 默认使用的是 nftables 作为防火墙和包过滤工具(UFW 实际上是 nftables 的前端),所以这里我们用 nftables 来进行 NAT 伪装。如果你用的是别的发行版,iptables 或者 FirewallD 也是可以进行 NAT 伪装的。
先在远端主机上允许 IP 转发:
1 | sudo sysctl -w net.ipv4.ip_forward=1 |
然后编辑远端主机上的 /etc/nftables.conf 文件,添加如下规则:
1 | table ip nat { |
保存,然后让 nftables 重载配置文件:
1 | sudo nft -f /etc/nftables.conf |
回到本地主机,修改我们刚刚添加的 Netplan 配置文件 /etc/netplan/70-usbip-ncm-host.yaml,为机房内网网段添加路由表:
1 | network: |
再次生成并且重载网络配置管理器的配置文件:
1 | sudo netplan generate |
此时在本地主机执行 ip route 查询路由表,应该就能看到我们刚刚添加的路由:
1 | 10.0.0.0/16 via 172.16.0.1 dev usb0 proto static metric 100 |
现在就可以愉快地用机房内网 IP 访问机房内网的资源啦!
1 | curl -i http://10.0.0.1 |
因为用了 NAT,所以本地主机在访问内网资源时,资源服务器看到的来源 IP 地址是远端主机的内网 IP 地址。
甚至可以更进一步:如果部分公网服务需要通过机房 IP 出口才能访问,可以再修改路由表,让除了远端主机的公网 IP 本地主机所在内网之外和的流量都走 USB CDC-NCM 的网络接口,从而实现「全局代理」的效果。不过这么操作就比较麻烦了,也很容易因为配置不当导致网络中断,这里就不展开讲了。
如果要在远端主机上的虚拟 UDC 上解绑 USB Gadget,执行以下指令即可:
1 | echo "" | sudo tee /sys/kernel/config/usb_gadget/usbip_ncm/UDC |
在本地主机上卸载 USB CDC-NCM 网络接口的方法和上面「正着用」的一样,这里就不再赘述了。
需要注意的是,ConfigFS 是基于内存的文件系统,这意味着系统重启后我们之前写入的 USB Gadget 配置都会丢失。如果要将 USB Gadget 持久化的话,可以写个脚本,在系统启动时自动创建 USB Gadget 并绑定到 usbip_vudc 创建的虚拟 UDC 上,或者考虑使用 Gadget Tool 之类的工具。Netplan 和 nftables 的配置文件不在 ConfigFS 中,本身就是持久化的,不需要额外处理。