使用EasyTier异地组网

封面
(封面图源EasyTier官网)

TL;DR

异地组网,我一直以来都有这个需求,便于在各种情况下访问我的所有内网设备。在曾经的折腾一番CloudFlare DDNS一文中我提到了使用开源的VPN协议进行异地组网,但这一直有几个弊端:

  1. 必须依赖中继服务器
  2. 只能单向访问,如需双向访问则还要另开一个ssh端口转发。
  3. 每多一个子网,就得多一条VPN连接。
    正好,在同学的安利下,碰到了这款叫EasyTier的开源项目。
    好耶,甚至是Rust开发的!

Rust Meme

架构思路

在EasyTier中,没有客户端服务器的概念,只有节点的概念,因为它是一个去中心化的网络架构。每个设备都是一个节点,EasyTier把每个节点放在同一个网段下,节点以局域网地址来访问另一节点的指定端口。
在工作的时候,需要一个公网服务器,用作STUN打洞,亦或做流量中转。

搭建目标

我要把公网服务器、手机、笔记本电脑放在同一个局域网下,其中手机通过流量上网,笔记本通过校园网WiFi上网。
其中手机和笔记本电脑要保证能正常走流量代理的情况下又不影响EasyTier组网,还要保证手机在校外能访问校内的一台服务器(10.1.2.3/16)。
因为EasyTier是以本地应用程序运行的,如果在电脑上直接裸跑,就会让系统防火墙在局域网内直接被架空,所以我们要部署到docker容器里做到网络隔离,又要保证能正常组网。

开始搭建

文件准备

我们先去release页面下载app-universal-release.apkeasytier-linux-x86_64-v2.4.5.zip两个包,一个是给安卓手机用的,一个是给我们的服务器和电脑用的。
解压后,我们只需要easytier-clieasytier-core两个就行,core用来组网,cli用来管理状态。

截止写稿,最新版本是2.4.5,请根据实际情况下载。

除安卓端,启动命令都是./easytier-core -c config.toml,后续默认你已将easytier-clieasytier-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" # UUID必须唯一
ipv4 = "172.16.32.247/24" # 该节点在网络中的CIDR格式ip地址
dhcp = false # 如为true则上面那行可以直接删去
listeners = [
   "tcp://0.0.0.0:11010", # 将本节点用作宣告服务器监听的端口。注意,后面要用这个端口,把它记住。
   "udp://0.0.0.0:11010", # 同上
   "wg://0.0.0.0:11011", # WireGuard入站,用于单向组网
]
rpc_portal = "0.0.0.0:0" # easytier-cli的通信地址,0端口表示随机

[network_identity] # 进入局域网的认证信息
network_name = "mynetwork"
network_secret = "your_password"

[flags]
no_tun = true # 这项若为false则可能与电脑上基于TUN的代理软件打架
private_mode = true # 只给我自己用做宣告服务器,不给别人用,必须要求认证信息正确才能连接,防止我这个机器加入别人的网络,或者被别人偷跑流量
relay_network_whitelist = "mynetwork" # 只允许对这些网络进行relay, 空格分割的通配符列表,如 "ab* abc",留空则不给任何网络relay

笔记本电脑端

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 = [ # 笔记本电脑不想当宣告服务器
# "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"
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" # 你的公网服务器监听的地址,your_port就是上面要你记住的宣告服务器的那个端口

[[proxy_network]]
cidr = "10.1.2.3/16" # 校内服务器。这样配置后会把校园网的10.1.0.0/16整个网段也映射到你的虚拟局域网里。

[flags]
no_tun = true # 防止和其他TUN类的代理软件打架
private_mode = true

安卓端

打开app,在basic settings里填入你这台设备的虚拟局域网ip地址,或者也可选择是否DHCP获取。
Network NameNetwork 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

或者使用这个脚本启动,可以自动管理socateasytire-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/16172.16.32.0/24的流量通过该节点出站。
(安卓不用做这一步)然后设置排除网卡,把docker0排除掉即可。


使用EasyTier异地组网
http://blog.coolenoch.ink/2025/10/07/Linux/25-使用EasyTier异地组网-251007/
作者
CoolestEnoch
发布于
2025年10月7日
许可协议