前言 在南京使用联通宽带接近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
运行系统:
OpenWRT,作为DMZone(非军事隔离区)主路由,直通:X540T2,桥接PVE VMBR1虚拟网桥,网段192.168.10.1/24。
OpenWRT,作为MZone(军事隔离区)路由,给一些实验性靶机提供网络,桥接PVE VMBR1、PVE VMBR2虚拟网桥。
黑群晖,直通SATA控制器,位于DMZone,直通:板载8125网卡。
ubuntu,运行一些服务,桥接PVE VMBR1。
拓扑如下: 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的防火墙和路由设置里还添加了如下规则,以保证位于隔离区的虚拟机不会访问到非隔离区的设备。
隔离OpenWRT禁止LAN的设备访问192.168.10.0网段。
隔离OpenWRT禁止LAN设备访问隔离OpenWRT的后台,并且未开启SSH。
隔离OpenWRT允许WAN设备访问隔离OpenWRT到后台。
主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里即可看到设备。
配置 允许子网路由 tailscale允许一台接入tailnet的设备向其他tailnet中的设备提供路由到其子网的功能,开启这个功能需要配置--accept-routes
和--advertise-routes
参数,在tailscale启动后,需要登录到admin console。因为我的OpenWRT是7x24小时接入到tailnet的,所以首先需要关闭key有效期。
然后点击Edit route settings of openwrt
,允许子网路由,勾选对应子网。
防火墙和网络接口 接着打开OpenWRT的网络->接口
设置,添加一个接口,不配置协议,设备选择tailscale0。
再打开网络->防火墙->常规
设置,添加一个Zone,名称tailscale,入站接受、出站接受、转发接受、勾选IP动态伪装、勾选MSS钳制、绑定设备tailscale0,允许tailscale转发到LAN,允许LAN转发到tailscale0。
这两步完成后基础功能应该可以正常使用了。
在手机上开启移动网,下载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查看:
默认情况下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
设置完成后在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。
然后再安装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里点击设备的详细信息,即可看到中继节点的延迟。
隔离区的设置 最后隔离区的OpenWRT要设置一下禁止访问tailnet的网段,隔离就要隔离的彻底。
总结 在领导机器上安装tailscale,发送一个邀请链接,将她的账号加入我的tailnet,即可通过192.168.10.4
远程到家中的主机。至于稳定性,则需要使用一段时间才能知道了。后面有时间可能会折腾一下headscale,但其实tailscale已经够用了。