(封面图源EasyTier官网 )
TL;DR 异地组网,我一直以来都有这个需求,便于在各种情况下访问我的所有内网设备。在曾经的折腾一番CloudFlare DDNS一文中我提到了使用开源的VPN协议进行异地组网,但这一直有几个弊端:
必须依赖中继服务器
只能单向访问,如需双向访问则还要另开一个ssh端口转发。
每多一个子网,就得多一条VPN连接。 正好,在同学的安利下,碰到了这款叫EasyTier的开源项目。好耶,甚至是Rust开发的!
架构思路 在EasyTier中,没有客户端、服务器的概念,只有节点的概念,因为它是一个去中心化的网络架构。每个设备都是一个节点,EasyTier把每个节点放在同一个网段下,节点以局域网地址来访问另一节点的指定端口。 在工作的时候,需要一个公网服务器,用作STUN打洞,亦或做流量中转。
搭建目标 我要把公网服务器、手机、笔记本电脑放在同一个局域网下,其中手机通过流量上网,笔记本通过校园网WiFi上网。 其中手机和笔记本电脑要保证能正常走流量代理的情况下又不影响EasyTier组网,还要保证手机在校外能访问校内的一台服务器(10.1.2.3/16)。 因为EasyTier是以本地应用程序运行的,如果在电脑上直接裸跑,就会让系统防火墙在局域网内直接被架空,所以我们要部署到docker容器里做到网络隔离,又要保证能正常组网。
开始搭建 文件准备 我们先去release页面 下载app-universal-release.apk和easytier-linux-x86_64-v2.4.5.zip两个包,一个是给安卓手机用的,一个是给我们的服务器和电脑用的。 解压后,我们只需要easytier-cli和easytier-core两个就行,core用来组网,cli用来管理状态。
截止写稿,最新版本是2.4.5,请根据实际情况下载。
除安卓端,启动命令都是./easytier-core -c config.toml,后续默认你已将easytier-cli和easytier-core放在工作目录,并已cd进去。后续是每个端上的配置文件。
公网服务器端 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 hostname = "my_serverr" instance_name = "mynetwork" instance_id = "11451419-1981-0114-5141-919810114514" ipv4 = "172.16.32.247/24" dhcp = false listeners = [ "tcp://0.0.0.0:11010" , "udp://0.0.0.0:11010" , "wg://0.0.0.0:11011" , ] rpc_portal = "0.0.0.0:0" [network_identity] network_name = "mynetwork" network_secret = "your_password" [flags] no_tun = true private_mode = true relay_network_whitelist = "mynetwork"
笔记本电脑端 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 hostname = "laptop" instance_name = "laptop" instance_id = "11451419-1981-0114-5141-919810114515" ipv4 = "172.16.32.248/24" dhcp = false listeners = [ ]rpc_portal = "0.0.0.0:0" socks5_proxy = "socks5://0.0.0.0:1145" [network_identity] network_name = "mynetwork" network_secret = "your_password" [[peer]] uri = "tcp://your_ip_or_domain:your_port" [[proxy_network]] cidr = "10.1.2.3/16" [flags] no_tun = true private_mode = true
安卓端 打开app,在basic settings里填入你这台设备的虚拟局域网ip地址,或者也可选择是否DHCP获取。Network Name和Network Secrect填入和上面配置文件里一样的。Networking Method选择Public Server,然后地址填写tcp://your_ip_or_domain:your_port,也和上面配置文件一样。重点来了 ,你如果要在组网的同时使用别的代理软件,就要打开No TUN Mode并添加Socks5 Server,然后去第三方代理软件里添加一条路由规则,这个会在文章最后讲到。
然后直接Run Network即可。
笔记本端的安全策略: 使用Docker隔离网络访问 因为EasyTier是以本地应用程序运行的,如果在电脑上直接裸跑,就会让系统防火墙在局域网内直接被架空,所以我们要部署到docker容器里做到网络隔离,又要保证能正常组网。
现假设你的电脑上运行着ssh服务(监听1919端口)和nginx页面(监听9180端口),并且这两个端口已被防火墙放通。
Docker容器 创建一个Docker容器,把1145端口(就是上面笔记本配置文件里的socks5_proxy端口)映射出来。或者可以直接抄我的作业:
1 sudo docker run -d --name alpine_easytier -p 1145:1145 -v /home/admin/easytier:/root -v /etc/localtime:/etc/localtime -dit alpine
这样在容器里运行easytier的话,别的节点通过局域网ip访问你的时候是无法直接访问宿主机对应端口的服务的(实际访问的是容器里对应的端口),所以我们要用端口转发器把流量从容器里转发到宿主机上。即使宿主机对应端口被防火墙拦了,别的节点也无法访问该服务,对别的节点来说是无感的了。 我们使用socat进行端口转发,这样更通用,因为在我的环境下,alpine容器里装iptables无法修改转发规则。
使用socat转发流量只须这一行命令即可(容器中宿主机ip是172.17.0.1):
1 2 socat TCP-LISTEN:1919,bind=0.0.0.0,fork,reuseaddr TCP:172.17.0.1:1919 socat TCP-LISTEN:9810,bind=0.0.0.0,fork,reuseaddr TCP:172.17.0.1:9810
或者使用这个脚本启动,可以自动管理socat和easytire-core的生命周期:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 TARGET_HOST="172.17.0.1" PORTS_TCP="1919 9810" PORTS_UDP="" cleanup() { echo "正在清理..." killall socat echo "所有 socat 进程已停止" exit } trap cleanup INT TERM killall socat echo "启动端口映射到 $TARGET_HOST..." for PORT in $PORTS_TCP; do echo "映射端口: 0.0.0.0:$PORT/tcp -> $TARGET_HOST:$PORT/tcp" socat TCP-LISTEN:$PORT,bind=0.0.0.0,fork,reuseaddr TCP:$TARGET_HOST:$PORT & done for PORT in $PORTS_UDP; do echo "映射端口: 0.0.0.0:$PORT/udp -> $TARGET_HOST:$PORT/udp" socat UDP-LISTEN:$PORT,bind=0.0.0.0,fork,reuseaddr UDP:$TARGET_HOST:$PORT & done echo "所有端口映射已启动" echo "按 Ctrl+C 停止并退出" ./easytier-core -c config.toml# 等待所有后台进程 wait
当然如果你要更高级的转发,支持ipv4和v6双栈,并且入口端口和出口端口不一样,那么可以参考下面这个脚本:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 DOCKER_HOST="172.11.0.1"# 用空格分隔模拟数组 PORTS_TCP_SOURCE="1919 9810" PORTS_TCP_TARGET="${DOCKER_HOST}:1145 ${DOCKER_HOST}:19198" PORTS_UDP_SOURCE="" PORTS_UDP_TARGET=""# ------------ 配置结束 ------------ set -eu PIDS="" TMPDIR=$(mktemp -d 2>/dev/null || echo "/tmp/portmap.$$") OUT_REDIRECT=">/dev/null 2>&1" # 默认不打印 socat 输出;改成 >>"$TMPDIR/socat-$src.log" 2>&1 可写日志 cleanup() { echo echo "正在清理... (停止所有子进程)" for pid in $PIDS; do if kill -0 "$pid" 2>/dev/null; then kill "$pid" 2>/dev/null || true wait "$pid" 2>/dev/null || true fi done [ -d "$TMPDIR" ] && rm -rf "$TMPDIR" echo "清理完成。" exit 0 } trap cleanup INT TERM# 检查依赖 if ! command -v socat >/dev/null 2>&1; then echo "错误:找不到 socat,请先安装。" >&2 exit 1 fi# ---------- helper ---------- expand_ports() { list="$1" for token in $list; do case "$token" in *-*) start=$(echo "$token" | cut -d- -f1) end=$(echo "$token" | cut -d- -f2) i=$start while [ "$i" -le "$end" ]; do echo "$i" i=$((i+1)) done ;; '') ;; *) echo "$token" ;; esac done }# parse host:port -> host port family(4/6) parse_host_port() { spec="$1" # if [ipv6]:port echo "$spec" | grep -q '^\[.*\]:' 2>/dev/null && { host=$(echo "$spec" | sed -e 's/^\[\(.*\)\]:.*$/\1/') port=$(echo "$spec" | sed -e 's/^.*:\([0-9][0-9]*\)$/\1/') echo "$host $port 6" return } # count colons colons=$(echo "$spec" | awk -F: '{print NF-1}') if [ "$colons" -ge 2 ]; then # treat last field as port, rest as IPv6 address port=$(echo "$spec" | awk -F: '{print $NF}') host=$(echo "$spec" | sed "s/:$port$//") echo "$host $port 6" return fi # normal host:port host=$(echo "$spec" | cut -d: -f1) port=$(echo "$spec" | cut -d: -f2-) echo "$host $port 4" } count_lines_in_file() { f="$1" if [ ! -f "$f" ]; then echo 0 else wc -l < "$f" | tr -d ' ' fi } normalize_pairs_file() { file_a="$1"; file_b="$2" na=$(count_lines_in_file "$file_a") nb=$(count_lines_in_file "$file_b") if [ "$na" -eq 0 ] && [ "$nb" -eq 0 ]; then return 0 fi if [ "$na" -eq "$nb" ]; then return 0 fi if [ "$na" -eq 1 ] && [ "$nb" -gt 1 ]; then val=$(sed -n '1p' "$file_a") rm -f "$file_a.tmp" i=1 while [ "$i" -le "$nb" ]; do echo "$val" >> "$file_a.tmp" i=$((i+1)) done mv "$file_a.tmp" "$file_a" return 0 fi if [ "$nb" -eq 1 ] && [ "$na" -gt 1 ]; then val=$(sed -n '1p' "$file_b") rm -f "$file_b.tmp" i=1 while [ "$i" -le "$na" ]; do echo "$val" >> "$file_b.tmp" i=$((i+1)) done mv "$file_b.tmp" "$file_b" return 0 fi echo "错误:端口数量不匹配 (src=$na tgt=$nb)" >&2 cleanup exit 1 }# ---------- 准备端口文件 ---------- src_tcp_file="$TMPDIR/src_tcp" tgt_tcp_file="$TMPDIR/tgt_tcp" src_udp_file="$TMPDIR/src_udp" tgt_udp_file="$TMPDIR/tgt_udp" expand_ports "$PORTS_TCP_SOURCE" > "$src_tcp_file" expand_ports "$PORTS_TCP_TARGET" > "$tgt_tcp_file" expand_ports "$PORTS_UDP_SOURCE" > "$src_udp_file" expand_ports "$PORTS_UDP_TARGET" > "$tgt_udp_file" normalize_pairs_file "$src_tcp_file" "$tgt_tcp_file" normalize_pairs_file "$src_udp_file" "$tgt_udp_file" echo "准备就绪:启动端口映射..."# ---------- 启动转发函数 ---------- # start_tcp srcport target_spec start_tcp() { src="$1" target_spec="$2" set -- $(parse_host_port "$target_spec") dst_host="$1"; dst_port="$2"; family="$3" if [ "$family" -eq 6 ]; then dst="TCP6:[$dst_host]:$dst_port" else dst="TCP:$dst_host:$dst_port" fi # 尝试启动 IPv6 dual-stack listener (ipv6only=0) listen6="TCP6-LISTEN:${src},bind=[::],fork,reuseaddr,ipv6only=0" eval socat $listen6 "$dst" $OUT_REDIRECT & pid=$! sleep 0.05 if kill -0 "$pid" 2>/dev/null; then # 成功启动 dual-stack listener PIDS="$PIDS $pid" else # 启动失败(可能内核不允许 ipv6only=0),回退为同时启动 IPv4 和 IPv6 两个 listener echo "notice: ipv6 dual-stack listener failed on port $src, fallback to split-listen" # IPv6 listener listen6b="TCP6-LISTEN:${src},bind=[::],fork,reuseaddr" eval socat $listen6b "$dst" $OUT_REDIRECT & PIDS="$PIDS $!" # IPv4 listener listen4="TCP-LISTEN:${src},bind=0.0.0.0,fork,reuseaddr" eval socat $listen4 "$dst" $OUT_REDIRECT & PIDS="$PIDS $!" fi }# start_udp srcport target_spec start_udp() { src="$1" target_spec="$2" set -- $(parse_host_port "$target_spec") dst_host="$1"; dst_port="$2"; family="$3" if [ "$family" -eq 6 ]; then dst="UDP6:[$dst_host]:$dst_port" else dst="UDP:$dst_host:$dst_port" fi listen6="UDP6-LISTEN:${src},bind=[::],fork,reuseaddr,ipv6only=0" eval socat $listen6 "$dst" $OUT_REDIRECT & pid=$! sleep 0.05 if kill -0 "$pid" 2>/dev/null; then PIDS="$PIDS $pid" else # fallback echo "notice: ipv6 dual-stack UDP listener failed on port $src, fallback to split-listen" eval socat "UDP6-LISTEN:${src},bind=[::],fork,reuseaddr" "$dst" $OUT_REDIRECT & PIDS="$PIDS $!" eval socat "UDP-LISTEN:${src},bind=0.0.0.0,fork,reuseaddr" "$dst" $OUT_REDIRECT & PIDS="$PIDS $!" fi }# ---------- 启动 TCP 映射 ---------- if [ "$(count_lines_in_file "$src_tcp_file")" -gt 0 ]; then # iterate lines in sync without using pipes/subshell exec 3<"$src_tcp_file" exec 4<"$tgt_tcp_file" while :; do if ! IFS= read -r src_port <&3; then break; fi if ! IFS= read -r tgt_spec <&4; then break; fi [ -z "$src_port" ] && continue echo "启动 TCP 映射: 0.0.0.0:${src_port} -> ${tgt_spec}" start_tcp "$src_port" "$tgt_spec" done exec 3<&- exec 4<&- fi# ---------- 启动 UDP 映射 ---------- if [ "$(count_lines_in_file "$src_udp_file")" -gt 0 ]; then exec 5<"$src_udp_file" exec 6<"$tgt_udp_file" while :; do if ! IFS= read -r src_port <&5; then break; fi if ! IFS= read -r tgt_spec <&6; then break; fi [ -z "$src_port" ] && continue echo "启动 UDP 映射: 0.0.0.0:${src_port} -> ${tgt_spec}" start_udp "$src_port" "$tgt_spec" done exec 5<&- exec 6<&- fi echo "所有映射已启动 (PIDs:$PIDS)。按 Ctrl+C 停止并退出。" ./easytier-core -c shanghai.toml# 等待所有后台进程 wait
和其他TUN代理的冲突问题 如果你开了easytier的tun模式,会和其他基于tun实现的代理软件对冲(在安卓上系统会限制只允许有一个VPN Service导致互挤);如果你部署到docker容器里的话,会导致无法连上宣告服务器。所以我们要进行如下处理:
假设你的容器已将socks5端口1145转发出来。
在代理软件里新增一个节点,地址是127.0.0.1:1145,设置代理软件的路由规则,发往10.1.0.0/16和172.16.32.0/24的流量通过该节点出站。 (安卓不用做这一步)然后设置排除网卡,把docker0排除掉即可。