基于OpenWRT使用Tailscale+自建Derper进行组网
lyq1996

前言

在南京使用联通宽带接近2年了,上行40Mbps,下行200Mbps。南京联通是可以申请公网IPV4的,使用起来体验还是很不错的。恰逢家里领导有办公室远程回家的需求,有公网当然很简单,但我一般用Termius SSH本地转发然后RDP(Windows)或者VNC(MacOS)回家,这样虽然比较安全但比较麻烦,不适合给领导用。然后又一开始想着Parsec将就将就,但是没想到她办公室死活连不上。所以了解了一下tailscale,并在自己的HomeLab中加入了tailscale组网,于是有了这篇博客。

简单介绍

tailscale基于WireGuard协议(WireGuard太强了),可以让不同的网络设备之间进行P2P互联。tailscale支持UDP打洞,也支持服务器中转。tailscale大部分是开源的,除了GUI以及后端控制器部分。Headscale则是完全开源重写、与官方无关联的开源tailscale后端控制器。因为tailscale积极拥抱开源,所以Headscale也得到了官方的兼容性支持,这使得tailscale客户端可以选择登录到开源自建的控制器上,例如这里说的Headscale。

Derper则是tailscale自研的DERP(Detoured Encrypted Routing Protocol)中继协议的server,不同于STUN,它运行在HTTP之上。所有的客户端会优先选择DERP进行中继,运行一段时间后如果发现可以直连,则透明升级到直连。DERP说白了就是一种保底,确保客户端能够在任何网络拓扑下互联。tailscale官方没有提供位于大陆的Derper,所以我们需要自建一个Derper,在无法直连时以获得最佳的使用体验。

HomeLab网络拓扑

我的HomeLab的核心是一台All In One,具体配置:

1
2
3
4
5
6
7
系统: PVE 8.0
CPU: 10900T ES(QTB0)
主板: 技嘉Z590 UD
网卡: X540 T2(10Gbpsx2)+板载RTL8125BG(2.5Gbpsx1)+USB千兆网卡AX88179(1Gbpsx1)
内存: 2x16 DDR4
硬盘: 2x4T(3.5) + 2x1T(2.5)
电源: 750w

运行系统:

  1. OpenWRT,作为DMZone(非军事隔离区)主路由,直通:X540T2,桥接PVE VMBR1虚拟网桥,网段192.168.10.1/24。
  2. OpenWRT,作为MZone(军事隔离区)路由,给一些实验性靶机提供网络,桥接PVE VMBR1、PVE VMBR2虚拟网桥。
  3. 黑群晖,直通SATA控制器,位于DMZone,直通:板载8125网卡。
  4. ubuntu,运行一些服务,桥接PVE VMBR1。

拓扑如下:
image
PVE管理口是一个USB AX88179千兆网卡,插到AP的千兆口上,由OpenWRT主路由分配IP地址。PVE里面我也为这个USB网卡设备创建了一个vmbr0网桥,但是已经有vmbr1、vmbr2了,所以基本用不上这个网桥。之所以要通过外接网卡连接管理口,是为了做OpenWRT宕机的情况下,仍然可以输入PVE的管理IP通过AP去访问PVE。

另外,我的域名托管在CloudFlare,指向OpenWRT主路由公网IP的子域名通过OpenWRT ddns插件自动更新,更新时机为检测到WAN接口IP变更,大约1个月会强制换一个IP。

隔离OpenWRT和主Openwrt的防火墙和路由设置里还添加了如下规则,以保证位于隔离区的虚拟机不会访问到非隔离区的设备。

  1. 隔离OpenWRT禁止LAN的设备访问192.168.10.0网段。
  2. 隔离OpenWRT禁止LAN设备访问隔离OpenWRT的后台,并且未开启SSH。
  3. 隔离OpenWRT允许WAN设备访问隔离OpenWRT到后台。
  4. 主OpenWRT添加路由规则192.168.11.0/24网段路由到隔离OpenWRT。

OpenWRT + Tailscale

安装

tailscale安装在主OpenWRT上,版本是22.03.5, 官方源里是有tailscale的,因此通过opkg即可安装:

1
➜  ~ opkg install tailscale

启动

1
2
3
➜  ~ tailscale down
➜ ~ tailscale up --accept-routes=true --accept-dns=false --advertise-routes=192.168.10.0/24,192.168.11.0/24 --reset --netfilter-mode=off
Warning: netfilter=off; configure iptables yourself.

参数解释:

1
2
3
4
5
1. --accept-routes=true 接受路由,允许其他tailnet里的设备路由到该设备子网。
2. --accept-dns=false 不要修改/etc/resolv.conf,待会手动配置。
3. --advertise-routes=192.168.10.0/24,192.168.11.0/24 通告该设备可以路由到192.168.10.0/24,192.168.11.0/24。
4. --reset 自动更新路由。
5. --netfilter-mode=off 不要自动设置防火墙,待会手动配置。

首次up会在终端打印一个登录的地址,点击然后登录账号,登录后在tailscale的admin console里即可看到设备。

image

配置

允许子网路由

tailscale允许一台接入tailnet的设备向其他tailnet中的设备提供路由到其子网的功能,开启这个功能需要配置--accept-routes--advertise-routes参数,在tailscale启动后,需要登录到admin console。因为我的OpenWRT是7x24小时接入到tailnet的,所以首先需要关闭key有效期。

image

然后点击Edit route settings of openwrt,允许子网路由,勾选对应子网。
image

防火墙和网络接口

接着打开OpenWRT的网络->接口设置,添加一个接口,不配置协议,设备选择tailscale0。
image

再打开网络->防火墙->常规设置,添加一个Zone,名称tailscale,入站接受、出站接受、转发接受、勾选IP动态伪装、勾选MSS钳制、绑定设备tailscale0,允许tailscale转发到LAN,允许LAN转发到tailscale0。
image

这两步完成后基础功能应该可以正常使用了。

在手机上开启移动网,下载tailsacle app登录简单测试一下,并在OpenWRT查看状态:

1
2
3
➜  ~ tailscale status
100.xx.xx.xx openwrt xxx@ linux -
100.xx.xx.xx samsung-sm-g9980 xxx@ android idle, tx 860 rx 948

可以看到,OpenWRT上的tailscale可以识别到新接入tailnet的手机。

接着在手机上打开Termux,测试子网路由是否正常:

1
2
3
4
5
6
7
8
9
10
11
12
~ $ ping 192.168.10.4
PING 192.168.10.4 (192.168.10.4) 56(84) bytes of data.
64 bytes from 192.168.10.4: icmp_seq=1 ttl=63 time=94.6 ms
64 bytes from 192.168.10.4: icmp_seq=2 ttl=63 time=48.6 ms
64 bytes from 192.168.10.4: icmp_seq=3 ttl=63 time=45.3 ms
64 bytes from 192.168.10.4: icmp_seq=4 ttl=63 time=43.8 ms
64 bytes from 192.168.10.4: icmp_seq=5 ttl=63 time=40.8 ms
64 bytes from 192.168.10.4: icmp_seq=6 ttl=63 time=40.0 ms
^C
--- 192.168.10.4 ping statistics ---
6 packets transmitted, 6 received, 0% packet loss, time 5009ms
rtt min/avg/max/mdev = 40.018/52.244/94.639/19.177 ms

子网路由也正常,192.168.10.4是内网中的一台主机,延迟在40ms左右,应该是直连的延迟。

DNS

当设备接入到一个tailnet时,会给设备分配一个IP地址,由100开头(100.x.y.z),这个地址属于Carrier Grade NAT(运营商及NAT地址CGNAT)地址,由RFC6598保留。当另一个设备也接入到相同的tailnet时,两个设备之间可以通过100.x.y.z互相访问。当然,IP地址有时候时很难记的,所以tailscale有一个功能叫Magic DNS,可以将格式为xxx.yyy.ts.net的域名解析到tailnet里的IP地址。这里xxx是设备的名称,一般自动生成,例如这台OpenWRT,名称直接就叫openwrt。yyy.ts.net是tailnet的名称,每个人都不一样,可以进入tailscale admin console查看:
image

默认情况下tailscale会自动搞定这件事。例如在OpenWRT,它会修改/etc/resolv.conf,将DNS服务器设置为100.100.100.100,并添加search domain为yyy.ts.net。而100.100.100.100又被本地运行的tailscale劫持,因此发往该地址的DNS请求会劫持到tailscale,tailscale就可以返回xxx.yyy.ts.net对应IP地址了。

我的DNS服务由dnsmasq提供,所以我不希望它修改/etc/resolv.conf,只想限定yyy.ts.net走100.100.100.100。好在dnsmasq是支持指定查询转发到上游解析器的。

打开OpenWRT的网络->DHCP/DNS->Forwards设置,添加一条DNS转发即可。

1
/yyy.ts.net/100.100.100.100

image

设置完成后在OpenWRT子网找一台设备测试一下:

1
2
3
4
5
6
➜  ~ nslookup xxx.yyy.ts.net                
Server: 192.168.10.1
Address: 192.168.10.1#53

Name: xxx.yyy.ts.net
Address: 100.xx.xx.xx

结果符合预期,xxx.yyy.ts.net由192.168.10.1(也就是这台OpenWRT)回复,地址正确。

OpenWRT + Derper

安装

Derper依赖go,因此需要先安装golang。

1
➜  ~ opkg install golang

然后再安装Derper。

1
➜  ~ go install tailscale.com/cmd/derper@main

配置

首先配置https证书,证书是Let’s Encrypt颁发的,通过certbot申请,因为443端口是关闭的,所以只能采用manual的方式申请。

首先是在OpenWRT上通过Docker运行certbot来申请证书。certbot的image不含curl和grep,而后面自动renew证书所使用的certbot hook script没有curl和grep会很难办,所以先基于certbot image添加curl和grep。Dockerfile如下:

1
2
3
4
➜  ~ cat cert-renew/Dockerfile 
FROM certbot/certbot

RUN apk add --no-cache curl grep

然后在构建image后,申请证书:

1
2
3
4
5
6
➜  ~ docker run --rm -i --name certbot
-v "/root/derper/cert-renew/letsencrypt:/etc/letsencrypt"
-v "/root/derper/cert-renew/var/lib/letsencrypt:/var/lib/letsencrypt"
-v "/root/derper/cert-renew/var/log:/var/log"
-v "/root/derper/cert-renew:/root/cert-renew"
certbot-with-curl certonly --manual --preferred-challenge dns -d xxx.yyy.zzz

运行时certbot会要求添加对应域名的TXT记录以验证域名的所有权,去对应域名服务商后台添加即可。验证通过后证书活动证书位于/root/derper/cert-renew/letsencrypt/live/xxx.yyy.zzz/

需要将fullchain.pem拷贝为xxx.yyy.zzz.crt,将privkey.pem拷贝为xxx.yyy.zzz.key,否则derper不识别证书。

自动更新证书则比较麻烦了,需要搞定更新过程中的域名所有权验证问题。certbot支持使用--manual-auth-hook验证所有权,原理是在验证过程中TXT记录的内容通过$CERTBOT_VALIDATION变量传递到--manual-auth-hook指定的脚本中,然后脚本负责更新TXT记录,脚本返回后certbot验证TXT记录是否已设置。我的域名托管在CloudFlare,很幸运它提供了API接口,因此我编写hook脚本如下。

txt_update.sh
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
#!/bin/sh

source /root/cert-renew/log.sh

# Declarations of names and ids of records.
NAME_OF_RECORD="_acme-challenge.xxxx.yyyy.zzzz"
AUTH_EMAIL="xxxx@xxxx.com"
AUTH_KEY="xxxxxx"
ZONE_NAME="yyyy.zzzz"

# Cheking AUTH was provided
if [ "$AUTH_KEY" = "Your_authorization_key" ] || [ "$AUTH_KEY" = "" ]; then
log "Missing Cloudflare API Key." >> /root/cert-renew/renew.log
exit 2
fi
if [ "$AUTH_EMAIL" = "Your_email_adress_in_cloudflare_services" ] || [ "$AUTH_EMAIL" = "" ]; then
log "Missing email address, used to create Cloudflare account." >> /root/cert-renew/renew.log
exit 2
fi
if [ "$ZONE_NAME" = "Your_zone_name" ] || [ "$ZONE_NAME" = "" ]; then
log "Missing zone name." >> /root/cert-renew/renew.log
exit 2
fi

# Obtaing zone ID
ZONE_ID=$(curl -s -X GET "https://api.cloudflare.com/client/v4/zones?name=$ZONE_NAME" -H "X-Auth-Email: $AUTH_EMAIL" -H "X-Auth-Key: $AUTH_KEY" -H "Content-Type: application/json" | grep -Po '(?<="id":")[^"]*' | head -1)

if [ "$ZONE_ID" = "" ]; then
log "Something went wrong" >> /root/cert-renew/renew.log
log $(curl -s -X GET "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records?name=$NAME_OF_RECORD" -H "X-Auth-Email: $AUTH_EMAIL" -H "X-Auth-Key: $AUTH_KEY" -H "Content-Type: application/json") >> /root/cert-renew/renew.log
exit 2
fi

# Get txt value
log "recode:"$CERTBOT_VALIDATION >> /root/cert-renew/renew.log

# Otherwise, your Internet provider changed your public IP again.
# Loop for all our records.

# Getting ID of record
ID=$(curl -s -X GET "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records?name=$NAME_OF_RECORD" -H "X-Auth-Email: $AUTH_EMAIL" -H "X-Auth-Key: $AUTH_KEY" -H "Content-Type: application/json" | grep -Po '(?<="id":")[^"]*' | head -1)
# Creating record with the new public IP address for Cloudflare using API v4
RECORD=$(
cat <<EOF
{ "type": "TXT",
"name": "$NAME_OF_RECORD",
"content": "$CERTBOT_VALIDATION",
"ttl": 1 }
EOF
)

RESPONSE=$(curl --silent "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records/$ID" \
-X PUT \
-H "Content-Type: application/json" \
-H "X-Auth-Email: $AUTH_EMAIL" \
-H "X-Auth-Key: $AUTH_KEY" \
-d "$RECORD")

if [ "$(log $RESPONSE | grep "\"success\":true")" != "" ]; then
# Saves new IP to file.
log "success" >> /root/cert-renew/renew.log
else
log "Something went wrong" >> /root/cert-renew/renew.log
log "Response: $RESPONSE" >> /root/cert-renew/renew.log
fi

sleep 60

定时运行certbot的命令:

1
2
3
4
5
6
docker run --rm --name certbot \
-v "/root/derper/cert-renew/letsencrypt:/etc/letsencrypt" \
-v "/root/derper/cert-renew/var/lib/letsencrypt:/var/lib/letsencrypt" \
-v "/root/derper/cert-renew/var/log:/var/log" \
-v "/root/derper/cert-renew:/root/cert-renew" \
certbot-with-curl renew --manual-auth-hook /root/cert-renew/txt_update.sh --post-hook /root/cert-renew/post_hook.sh

post_hook.sh需要重启derper服务,将fullchain.pem拷贝为xxx.yyy.zzz.crt,将privkey.pem拷贝为xxx.yyy.zzz.key,大致内容如下。

1
2
3
4
5
6
7
8
9
10
11
12
cp $(readlink -f "$CERT_RENEW_DIR/letsencrypt/live/xxx.yyy.zzz/cert.pem") "$CERT_SRC_DIR/cert.pem" || error_exit "Halting because of error copying cert.pem"
cp $(readlink -f "$CERT_RENEW_DIR/letsencrypt/live/xxx.yyy.zzz/chain.pem") "$CERT_SRC_DIR/chain.pem" || error_exit "Halting because of error copying chain.pem"
cp $(readlink -f "$CERT_RENEW_DIR/letsencrypt/live/xxx.yyy.zzz/fullchain.pem") "$CERT_SRC_DIR/fullchain.pem" || error_exit "Halting because of error copying fullchain.pem"
cp $(readlink -f "$CERT_RENEW_DIR/letsencrypt/live/xxx.yyy.zzz/privkey.pem") "$CERT_SRC_DIR/privkey.pem" || error_exit "Halting because of error copying privkey.pem"

rm $CERT_SRC_DIR/xxx.yyy.zzz.crt
openssl x509 -inform PEM -in $CERT_SRC_DIR/fullchain.pem -out $CERT_SRC_DIR/xxx.yyy.zzz.crt

rm $CERT_SRC_DIR/xxx.yyy.zzz.key
cp $CERT_SRC_DIR/privkey.pem $CERT_SRC_DIR/xxx.yyy.zzz.key

service derper restart

启动

证书配置完成后通过service的方式启动derper,添加/etc/init.d/derper文件,设置权限755,填入下面的内容添加derper服务。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#!/bin/sh /etc/rc.common

START=99

SERVICE_USE_PID=1
SERVICE_WRITE_PID=1
SERVICE_DAEMONIZE=1

start() {
service_start /root/go/bin/derper --certmode=manual --certdir=/root/derper/certs --hostname=xxx.yyy.zzz -a :7006 -stun-port 7007 -http-port -1 --verify-clients
}

stop() {
service_stop /root/go/bin/derper
}

这里https地址监听7006,stun地址监听7007,关闭http监听,并且验证客户端(--verify-clients设置后,derper会向本地运行的tailscale验证客户端是否允许接入,所以需要derper同时也是Tailscale节点)。

配置

运行后在tailscale admin console里进入ACL设置,添加derper节点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
"derpMap": {
// 所有节点是否只使用自建中继
"OmitDefaultRegions": true,
"Regions": {
"900": {
"RegionID": 900,
"RegionCode": "test",
"Nodes": [
{
"Name": "test-1",
"RegionID": 900,
"HostName": "xxx.yyy.zzz",
"DERPPort": 7006,
"STUNPort": 7007,
},
],
},
},
},

然后当一个设备通过中继接入tailnet时,在admin console里点击设备的详细信息,即可看到中继节点的延迟。
image

隔离区的设置

最后隔离区的OpenWRT要设置一下禁止访问tailnet的网段,隔离就要隔离的彻底。
image

总结

在领导机器上安装tailscale,发送一个邀请链接,将她的账号加入我的tailnet,即可通过192.168.10.4远程到家中的主机。至于稳定性,则需要使用一段时间才能知道了。后面有时间可能会折腾一下headscale,但其实tailscale已经够用了。

 评论
评论插件加载失败
正在加载评论插件
由 Hexo 驱动 & 主题 Keep
本站由 提供部署服务
访客数 访问量