DreamCity

愿你有一天,能与你最重要的人重逢

开始之前先叠个甲:这篇博文仅供技术交流和娱乐(标题里也说了是在整烂活了),文中提到的以及你能想到的应用场景实际上都有更加成熟的解决方案,部分操作也不一定符合部分工作单位的操作规范,甚至可能存在相当大的安全风险。所以把这篇博文当个乐子看就行啦,如果能对你有一定的启发那就更好了。

我不是专业的网络工程师,也不是专业的 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
2
3
4
sudo modprobe usbip_core # USB/IP 的核心模块
sudo modprobe usbip_host # USB/IP 服务端的主机驱动,服务端需要加载
sudo modprobe vhci_hcd # USB/IP 客户端的虚拟主机控制器驱动,客户端需要加载
# modprobe 加载的模块不会持久化,如果需要开机自动加载模块,可以在 /etc/modules-load.d/ 目录下新建一个 .conf 文件,将模块名称写入,一行一个

如果你在加载内核模块时遇到了 FATAL: Module usbip_core not found 这样的错误,一般是你的发行版自带的内核把 USB/IP 相关的模块裁剪掉了(一般来说是云计算服务商在为自家虚拟机平台优化内核时干的,比如 Azure)。你可能需要安装内核模块扩展包 linux-modules-extra,或者换用 linux-image-generic 之类的通用内核,或者自己手动编译一个内核,或者通过 DKMS 等方式加载这几个模块。

1
2
3
4
5
6
# 安装内核模块扩展包
sudo apt install linux-modules-extra-$(uname -r) # Ubuntu / Debian
sudo dnf install kernel-modules-extra-$(uname -r) # Fedora / RHEL
# 或者换用通用内核,并且一起安装通用内核的内核模块扩展包
sudo apt install linux-modules-extra-generic # Ubuntu / Debian
sudo dnf install kernel # Fedora / RHEL

部分发行版的内核可能有编译 USB/IP 的内核模块,但是裁剪掉了 USB/IP 的用户空间工具。不过这不是什么大问题,安装 linux-tools 即可补全:

1
2
sudo apt install linux-tools-$(uname -r)  # Ubuntu / Debian
sudo dnf install kernel-tools-$(uname -r) # Fedora / RHEL

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
2
3
4
# 这里列出的是我的 Realtek RTL8153 USB 网卡
- busid 2-1 (0bda:8153)
Realtek Semiconductor Corp. : RTL8153 Gigabit Ethernet Adapter (0bda:8153)
# 如果你有接入更多 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
2
3
4
5
6
7
Exportable USB devices
======================
- <USB_IP_SERVER>
2-1: Realtek Semiconductor Corp. : RTL8153 Gigabit Ethernet Adapter (0bda:8153)
: /sys/devices/platform/scb/fd500000.pcie/pci0000:00/0000:00:00.0/0000:01:00.0/usb2/2-1
: (Defined at Interface level) (00/00/00)
: 0 - Vendor Specific Class / Vendor Specific Subclass / unknown protocol (ff/ff/00)

记下要挂载的 USB 网卡的 busid,然后执行以下指令将其挂载到本地:

1
sudo usbip attach -r <USB_IP_SERVER> -b 2-1 # 替换成你的 USB/IP 服务器的地址和 busid

挂载完成后,稍等大约半分钟,等系统识别到设备并加载好驱动后,应该就能在 lsusb 的输出里看到这张 USB 网卡了:

1
2
3
Bus 001 Device 001: ID 1d6b:0002 Linux Foundation 2.0 root hub
Bus 002 Device 001: ID 1d6b:0003 Linux Foundation 3.0 root hub
Bus 002 Device 002: ID 0bda:8153 Realtek Semiconductor Corp. RTL8153 Gigabit Ethernet Adapter

ip a 的输出里应该也能看到多出了一个网络接口:

1
2
3
# 网络接口的名字可能不一样,我这里是 eth1
2: eth1: <BROADCAST,MULTICAST> mtu 1500 qdisc fq state DOWN group default qlen 1000
link/ether 00:e0:4c:**:**:** brd ff:ff:ff:ff:ff:ff

但是目前这个网络接口是 DOWN 的,也没有配置 IP 地址和路由表,还不能直接使用。在 Ubuntu Server 24.04 LTS 上,我们可以通过 Netplan 来配置这个网络接口。在 /etc/netplan/ 目录下新建一个 Netplan 配置文件 99-usbip-nic.yaml

1
2
3
4
5
6
7
network:
version: 2
renderer: networkd # Ubuntu Server 24.04 LTS 使用 systemd-networkd 作为网络配置管理器
ethernets:
eth1: # 这里改成你的网络接口名称
dhcp4: true # 这里是直接让 eth1 通过 DHCP 获取 IPv4 地址,你也可以手动指定静态 IP 和路由表
optional: true # 标记这个网络接口不是必须的,如果不存在也不会影响系统启动

执行以下指令生成网络配置管理器的配置文件:

1
sudo netplan generate

让网络配置管理器重载配置文件:

1
sudo networkctl reload

然后拉起网络接口:

1
sudo ip link set eth1 up # 记得把 eth1 改成你的网络接口的名称

此时再次查看 ip a 的输出,应该就能看到 eth1 已经成功获取到了 IP 地址:

1
2
3
4
5
6
2: eth1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq state UP group default qlen 1000
link/ether 00:e0:4c:**:**:** brd ff:ff:ff:ff:ff:ff
inet 192.168.1.2/24 metric 100 brd 192.168.1.255 scope global dynamic eth1
valid_lft 86400sec preferred_lft 86400sec
inet6 fe80::2e0:4cff:fe**:****/64 scope link
valid_lft forever preferred_lft forever

接下来就可以愉快地用内网 IP 访问内网资源啦!

1
2
3
curl -i http://192.168.1.1
# HTTP/1.1 200 OK
# ...

如果要在远端主机上卸载 USB 网卡,需要先执行以下指令,查看当前挂载的 USB 设备列表:

1
sudo usbip port

输出应该是这样的:

1
2
3
4
5
6
Imported USB devices
====================
Port 15: <Port in Use> at Super Speed(5000Mbps)
Realtek Semiconductor Corp. : RTL8153 Gigabit Ethernet Adapter (0bda:8153)
3-1 -> usbip://<USB_IP_SERVER>:3240/2-1
-> remote bus/dev 002/002

记下要卸载的 USB 网卡对应的 Port,然后执行以下指令卸载 USB 网卡:

1
sudo usbip detach -p 15 # 替换成你自己的 Port

此时再查看 lsusbip 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
2
ls /sys/class/udc/
# usbip-vudc.0

现在虚拟的 USB UDC 已经有了,但是我们要怎么定义这个 USB 设备,让其变成一张 USB 网卡呢?

与其去逆向各家 USB 网卡的专有驱动,这里我选择的方案是 USB CDC-NCM,也就是 Android 12 之后的 USB 网络共享方案(不过大部分国产手机厂商都还在用 RNDIS,微软的专有协议)。近年来的操作系统基本都有支持 CDC-NCM,兼容性应该还是 OK 的。

在远端主机上加载 libcomposite 和 CDC-NCM 功能组件内核模块:

1
2
sudo modprobe libcomposite
sudo modprobe usb_f_ncm

创建 USB Gadget 实例:

1
2
# /sys/kernel/config 目录是 ConfigFS 的默认挂载点
sudo mkdir -p /sys/kernel/config/usb_gadget/usbip_ncm

只需要创建这个目录,ConfigFS 和 libcomposite 就会在该目录下生成一堆用来配置 Gadget 属性的接口:

1
2
3
# 还记得吗?Linux 的哲学是「一切皆文件」
ls /sys/kernel/config/usb_gadget/usbip_ncm
# UDC bDeviceClass bDeviceProtocol bDeviceSubClass bMaxPacketSize0 bcdDevice bcdUSB configs functions idProduct idVendor max_speed os_desc strings webusb

进入刚刚创建的 USB Gadget 实例目录,写入设备标识符:

1
2
3
4
5
6
7
cd /sys/kernel/config/usb_gadget/usbip_ncm
echo 0x1d6b | sudo tee idVendor # Linux Foundation 的 Vendor ID
echo 0x0104 | sudo tee idProduct # Multifunction Composite Gadget 的 Product ID
sudo mkdir -p strings/0x409 # 人类可读的字符串描述符目录,0x409 是美式英语的语言 ID
echo "114514" | sudo tee strings/0x409/serialnumber # 设备序列号,需要确保相同 Vendor ID 和 Product ID 下的设备序列号唯一
echo "USB/IP" | sudo tee strings/0x409/manufacturer # 设备制造商
echo "USB/IP CDC-NCM Network Adapter" | sudo tee strings/0x409/product # 设备名称

然后在 USB Gadget 中创建 CDC-NCM 模块实例:

1
sudo mkdir -p functions/ncm.usb0

同样地,目录创建后会生成一堆接口:

1
2
ls functions/ncm.usb0
# dev_addr host_addr ifname max_segment_size os_desc qmult

其中 dev_addrhost_addr 分别是 USB 设备端(也就是 USB/IP 服务端)和 USB 主机端(也就是 USB/IP 客户端)的网络接口的 MAC 地址。内核会直接在这两个接口中随机生成 MAC 地址,但是这里有个坑:如果不手动写入一次 MAC 地址的话,后续 USB 设备端的 CDC-NCM 网络接口的 MAC 地址会变成系统随机生成的另一个 MAC 地址。所以我们必须手动写入一次 MAC 地址,哪怕只是读取后再原样写入:

1
2
3
# 当然,你也可以写入自定义的 MAC 地址
echo $(cat functions/ncm.usb0/dev_addr) | sudo tee functions/ncm.usb0/dev_addr
echo $(cat functions/ncm.usb0/host_addr) | sudo tee functions/ncm.usb0/host_addr

为什么会有两个 MAC 地址?

因为 CDC-NCM 实际上是将两端都当成独立的网络节点,在两端都创建了一个虚拟网络接口,再在网络接口之间建立了一个点对点的以太网连接,相当于两个虚拟网络接口直接通过一条网线连接在一起。

想象一下 Android 手机通过 USB 共享网络的场景就很好理解了。在这个场景下,手机实际上承担起了路由器和网关的作用,电脑(USB 主机端)需要先将数据包发送给手机,手机(USB 设备端)再将数据包转发到公网。所以手机这边需要一个网络接口和一个 MAC 地址,电脑这边也需要一个网络接口和一个 MAC 地址。

为 Gadget 创建 USB 配置:

1
2
3
sudo mkdir -p configs/c.1/strings/0x409 # 同上,0x409 是美式英语的语言 ID
echo "CDC-NCM Config" | sudo tee configs/c.1/strings/0x409/configuration # 写入 USB 配置名称
sudo ln -s functions/ncm.usb0 configs/c.1/ # 将 CDC-NCM 功能组件链接到 USB 配置中

将 USB Gadget 绑定到 usbip_vudc 创建的虚拟 UDC 上:

1
echo usbip-vudc.0 | sudo tee UDC

稍等半分钟,ip a 的输出中应该就能看到 CDC-NCM 的 USB 设备端网络接口了:

1
2
3
2: usb0: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN group default qlen 1000
link/ether 9e:af:3d:34:96:33 brd ff:ff:ff:ff:ff:ff
# 这里的 MAC 地址应该就是 dev_addr 接口中写入的地址

尝试拉起网络接口:

1
sudo ip link set usb0 up

此时再次执行 ip a,会发现网络接口还是 DOWN 的,但是比刚才多了一个 NO-CARRIER 的 Flag:

1
2
2: usb0: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc fq_codel state DOWN group default qlen 1000
link/ether 9e:af:3d:34:96:33 brd ff:ff:ff:ff:ff:ff

这是因为本地主机还没有挂载上 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
2
3
4
5
6
Exportable USB devices
======================
- <USB_IP_SERVER>
usbip-vudc.0: Linux Foundation : Multifunction Composite Gadget (1d6b:0104)
: /sys/devices/platform/usbip-vudc.0
: (Defined at Interface level) (00/00/00)

看!这里的 busid 变成了 usbip-vudc.0,也就是我们 usbip_vudc 创建的虚拟 UDC,设备制造商和设备类型也和我们刚刚写入 idVendoridProduct 接口的一致。

将 CDC-NCM 的 USB 主机端网络接口挂载到本地:

1
sudo usbip attach -r <USB_IP_SERVER> -b usbip-vudc.0

稍等一会儿,等系统识别到 Gadget 并加载好驱动后,应该就能在 lsusb 的输出里看到我们的 USB Gadget 了:

1
2
3
Bus 001 Device 001: ID 1d6b:0002 Linux Foundation 2.0 root hub
Bus 001 Device 002: ID 1d6b:0104 Linux Foundation Multifunction Composite Gadget
Bus 002 Device 001: ID 1d6b:0003 Linux Foundation 3.0 root hub

ip a 的输出中也能看到多了一个网络接口:

1
2
3
3: usb0: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN group default qlen 1000
link/ether 16:8c:7f:33:79:6f brd ff:ff:ff:ff:ff:ff
# 这里的 MAC 地址应该就是 host_addr 接口中写入的地址

拉起这个网络接口:

1
sudo ip link set usb0 up

此时查看两端的 ip a 的输出,会发现网络接口的状态都变成了 UP:

1
2
3
4
5
6
7
8
9
10
11
# 远端主机,USB 设备端
2: usb0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000
link/ether 9e:af:3d:34:96:33 brd ff:ff:ff:ff:ff:ff
inet6 fe80::9caf:3dff:fe34:9633/64 scope link
valid_lft forever preferred_lft forever

# 本地主机,USB 主机端
3: usb0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq state UP group default qlen 1000
link/ether 16:8c:7f:33:79:6f brd ff:ff:ff:ff:ff:ff
inet6 fe80::148c:7fff:fe33:796f/64 scope link
valid_lft forever preferred_lft forever

这样两边的 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
2
3
4
5
6
7
8
9
network:
version: 2
renderer: networkd # Ubuntu Server 24.04 LTS 使用 systemd-networkd 作为网络配置管理器
ethernets:
usb0: # 这里改成你的网络接口名称
addresses:
- 172.16.0.1/24 # USB 设备端网络接口的 IP 和子网
# 不要写网关,因为远端主机的默认路由应该是指向机房内网的网关,已经在其他网络接口上配置好了
optional: true # 标记这个网络接口不是必须的,如果不存在也不会影响系统启动

然后在本地主机上的相同位置新建一个 Netplan 配置文件 70-usbip-ncm-host.yaml,写入以下内容,设置 USB 主机端的网络接口的静态 IP:

1
2
3
4
5
6
7
8
9
network:
version: 2
renderer: networkd # Ubuntu Server 24.04 LTS 使用 systemd-networkd 作为网络配置管理器
ethernets:
usb0: # 这里改成你的网络接口名称
addresses:
- 172.16.0.2/24 # USB 主机端网络接口的 IP 和子网
# 不要写网关,否则路由表中可能会多出一条指向 CDC-NCM 设备端的 default route,影响网络正常连接
optional: true # 标记这个网络接口不是必须的,如果不存在也不会影响系统启动

然后在两端都生成并重载网络配置管理器的配置文件:

1
2
sudo netplan generate
sudo networkctl reload

这个时候查看 ip a,应该就能看到两边的 USB CDC-NCM 网络接口都配置好静态 IP 地址了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 远端主机,USB 设备端
2: usb0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000
link/ether 9e:af:3d:34:96:33 brd ff:ff:ff:ff:ff:ff
inet 172.16.0.1/24 brd 172.16.0.255 scope global usb0
valid_lft forever preferred_lft forever
inet6 fe80::9caf:3dff:fe34:9633/64 scope link
valid_lft forever preferred_lft forever

# 本地主机,USB 主机端
3: usb0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000
link/ether 16:8c:7f:33:79:6f brd ff:ff:ff:ff:ff:ff
inet 172.16.0.2/24 brd 172.16.0.255 scope global noprefixroute usb0
valid_lft forever preferred_lft forever
inet6 fe80::148c:7fff:fe33:796f/64 scope link tentative noprefixroute
valid_lft forever preferred_lft forever

然后两端就能互相访问了:

1
2
3
4
5
6
7
8
9
10
11
# 本地主机访问远端主机
telnet 172.16.0.1 22
# Trying 172.16.0.1...
# Connected to 172.16.0.1.
# ...

# 远端主机访问本地主机
telnet 172.16.0.2 22
# Trying 172.16.0.2...
# Connected to 172.16.0.2.
# ...

现在来配置 NAT。Ubuntu Server 24.04 LTS 默认使用的是 nftables 作为防火墙和包过滤工具(UFW 实际上是 nftables 的前端),所以这里我们用 nftables 来进行 NAT 伪装。如果你用的是别的发行版,iptables 或者 FirewallD 也是可以进行 NAT 伪装的。

先在远端主机上允许 IP 转发:

1
2
sudo sysctl -w net.ipv4.ip_forward=1
# 重启后会失效,如果需要持久化,可以编辑 /etc/sysctl.conf,取消 net.ipv4.ip_forward=1 前面的注释

然后编辑远端主机上的 /etc/nftables.conf 文件,添加如下规则:

1
2
3
4
5
6
7
8
table ip nat {
chain postrouting {
# 在数据包确定了路由、即将发出去之前进行 NAT 伪装
type nat hook postrouting priority srcnat;
# 对来自 172.16.0.0/24 网段、出口为 eth0 的数据包进行 NAT 伪装
oifname "eth0" ip saddr 172.16.0.0/24 masquerade;
}
}

保存,然后让 nftables 重载配置文件:

1
sudo nft -f /etc/nftables.conf

回到本地主机,修改我们刚刚添加的 Netplan 配置文件 /etc/netplan/70-usbip-ncm-host.yaml,为机房内网网段添加路由表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
network:
version: 2
renderer: networkd # Ubuntu Server 24.04 LTS 使用 systemd-networkd 作为网络配置管理器
ethernets:
usb0: # 这里改成你的网络接口名称
addresses:
- 172.16.0.2/24 # USB 主机端的 CIDR
# 不要写网关,否则路由表中可能会多出一条指向 CDC-NCM 设备端的默认路由,影响网络正常连接
optional: true # 标记这个网络接口不是必须的,如果不存在也不会影响系统启动
# 添加路由
routes:
- to: 10.0.0.0/16 # 要访问的机房内网网段的 CIDR
via: 172.16.0.1 # 经由 172.16.0.1,也就是 USB 设备端的 CDC-NCM 网络接口的 IP
# 如果机房内网有多个网段,可以继续添加更多路由

再次生成并且重载网络配置管理器的配置文件:

1
2
sudo netplan generate
sudo networkctl reload

此时在本地主机执行 ip route 查询路由表,应该就能看到我们刚刚添加的路由:

1
2
10.0.0.0/16 via 172.16.0.1 dev usb0 proto static metric 100
172.16.0.0/24 dev usb0 proto kernel scope link src 172.16.0.2 metric 100

现在就可以愉快地用机房内网 IP 访问机房内网的资源啦!

1
2
3
curl -i http://10.0.0.1
# HTTP/1.1 200 OK
# ...

因为用了 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 中,本身就是持久化的,不需要额外处理。

This article was last updated on days ago, and the information described in the article may have changed.