Tailscale/Headscale自建异地组网
曾经用过一段时间的Zerotier作为异地组网/内网穿透的工具,用了一段时间感觉还行。
后来听说了Tailscale这个工具,再后来了解到了还有Headscale这样的开源实现,觉得这玩意似乎更加优雅,于是,折腾了半个晚上后总算把网络组起来了。在这里记录一下详细的过程。
术语
这类网络工具总是存在非常多的专有名词,所以得稍微先介绍一下:
Tailscale
Tailscale是一个基于WireGuard协议的组网工具。这一协议的好处是,在建立设备间通信时会尝试打洞,从而实现设备之间的P2P点对点通信,即便这些设备位于一些NAT网关后(不过根据NAT类型不同,打洞成功的可能性也不同,可参见:How NAT traversal works)。
Tailscale的工作流程不准确概括如下,如果希望更深入了解,可以阅读Tailscale: How it works。
- Tailscale官方搭设了一系列中央服务器,为用户提供注册、通信建立与数据中继等功能。
- 用户在需要组网的设备上安装Tailscale客户端,登录账号并加入虚拟子网。
- 虚拟子网建立后,所有子网内设备在通信时,将先与Tailscale中央服务器进行通信,判断两个设备间能否直连(打洞)。
- 如果能打洞,直连(DIRECT);如果不能打洞,则两个设备间的通信将需要中继服务器转发流量,即中继连接(RELAY)。
Tailscale服务器
值得注意的是,在Tailscale中,中央服务器和中继服务器可以是不同的服务器。
- 中央服务器, Control Server: 控制用户认证、存储组网配置等信息。
- 中继服务器, DERP Server: 专用于在设备间无法建立P2P直接通信时,转发设备间流量实现互相访问。
值得注意的是,所有设备间流量在网络上传输时都是加密的,且私钥都在设备本地,因此通常认为中继服务器是无法解密传输流量的。
Headscale
Tailscale官方尽管允许自行部署DERP服务器,但Tailscale中央服务器程序是闭源的,官方也不允许我们部署。而人们又常常因为一些原因,想要或者需要搭建自己控制的中央服务器,真正地将所有信息都掌控在可控范围内。
因此,Headscale,也就是今天我们要介绍的主角出现了。Headscale是Tailscale中央服务器程序的开源实现,并采用了BSD协议发行,我们可以任意部署到我们的服务器上。
有意思的是,Headscale的一位主要开发者实际上是Tailscale公司的雇员,Headscale的开发也受到了Tailscale公司的支持,参见: Making heads or tails of open source。
除了基本的Headscale程序,我们今天还会稍稍提一嘴Headscale-ui,这是一个管理Headscale服务器的Web UI界面,可以免去敲命令管理Headscale组网的烦恼。一般来说需要为Headscale配置API Key,这点我们后面说。
技术细节
这篇文章采用的小技术细节:
- Headscale运行在Docker上,方便管理;
- Nginx实现反向代理;
- 提供Web UI管理Headscale服务器;
- 使用一个子域名如
myvlan.example.com
作为访问入口; - 在已托管有好几个网站的服务器上搭设,因此如TLS证书、Nginx配置等都与现有其他网站兼容。
配置Headscale服务器
准备工作
显然,在开始配置自己的Headscale中央服务器之前,我们需要:
- 一台具备公网IP的服务器(至少对你来说,这个IP地址应当确保在你的应用场景下,总是可达的)
- 一个可用的域名(没有也能组网,但域名能省去非常多麻烦。同时,也假设已经为该域名配置好了HTTPS与证书)
- Docker(直接在本机系统上设置也行,具体可以参考安装文档)
- Nginx(如果你的服务器同时还运行着其他网站,需要充当反向代理的角色)
接下来让我们开始吧!
准备配置文件
首先我们需要前往Headscale的Github仓库,复制一份配置文件样板config-example.yaml。
按需要修改配置文件,可以参考下面我的示例,一些无关紧要的项目注释已被删除,有需要可以参考上述完整的样板文件。
1 |
|
写好以后,保存,文件名为config.yaml
。
准备Docker
我们新建一个文件夹,用于放置我们的配置文件等等,假设就叫hs-server
吧。
在里面新建一个文件夹,并把刚刚我们编写好的config.yaml
放进来,假设就叫hs-etc
吧,这个文件稍后会被映射到容器的/etc/headscale
文件夹。
接下来我们要使用Docker Compose创建容器,我们回到hs-server
文件夹,新建docker-compose.yml
文件如下。
1 | services: |
完成后,保存。
现在我们的文件夹hs-server
下应该包含以下内容:
1 | hs-server/ |
启动容器
Docker,启动!
1 | docker compose up -d |
可以curl访问一下刚刚映射的主端口,检查Headscale是否正常启动。默认访问/
结点结果是404,所以记得用-D -
输出响应头检查确认。
1 | user@MyServer:~$ curl -D - http://127.0.0.1:10080 |
开放防火墙(可选)
如果配置了DERP服务器,请务必记得在系统防火墙与云服务商控制面板中放行对应端口,例如上面示例配置文件中的10086
端口的UDP通信。
配置Nginx反向代理
不过,到目前为止,我们搭建的Headscale中央服务器还没有映射到我们的子域名,如myvlan.example.com
上。
而且,还记得我们前面提到,最好为服务器启用TLS加密吗?在这一套实现中,这相关的工作也要交给Nginx完成。
这里放上Nginx的示例配置文件,按需修改其中使用的子域名、SSL证书路径、反向代理Headscale服务器地址等参数后,就可以和其他Nginx配置一起使用了。
1 | map $http_upgrade $connection_upgrade { |
重启Nginx,访问子域名,看是否能响应HTTP请求。
开始组网
添加用户
在Tailscale中,每个组成的虚拟子网,都被称为一个Tailnet。一个Tailnet可以加入多台设备,且在一个Tailnet内,所有设备彼此可见,通过一个局域网IP进行互相访问。而即便是在同一个中央服务器上,隶属于不同Tailnet的设备,彼此之间是不可见的。
而在Headscale中,与 Tailnet 相对应的概念是 User。将不同的设备加入到一个User下,就意味着这些设备隶属于同一个用户,该用户下的所有设备共享一个虚拟子网。
现在,让我们来创建一个用户(子网)吧!在中央服务器上执行命令:
1 | docker exec -it headscale headscale users create <username> |
这里请把<username>
替换成任意喜欢的名称,执行后即可成功创建。
Users还是Namespaces?
许多关于Headscale的文档还使用headscale namespaces
命令管理子网,似乎它的功能和users
差不多?
其实,在2023年1月(似乎是v0.19.0)之后的版本中,Headscale将所有namespaces
相关的命令和概念都换成了users
。
所以,如果使用的是新版的Headscale,请按照users
进行使用。
参见以下Issues
注册设备
常规方法
现在,我们需要将我们想要彼此连接的设备添加到刚刚创建的用户(子网)中。
在设备上安装好Tailscale客户端后,打开终端或命令提示符,执行以下命令。
1 | tailscale login --login-server https://myvlan.example.com |
命令行会返回类似如下的信息:
1 | To authenticate, visit: |
可以继续访问上面的链接,网页会告诉我们,需要前往Headscale中央服务器执行一串命令。实际上,这行命令的作用就是让Headscale中央服务器接受我们在客户端上的入网请求。
我们也可以注意到,在刚刚给出的链接中最后一部分,例如上面链接的ab-CDE_fghijkl0123456789
,其实就是认证用的密钥。
让我们回到中央服务器,执行命令:
1 | docker exec -it headscale headscale nodes register -u <username> -k mkey:<yourkey> |
请将<username>
替换成用户名(子网名),<yourkey>
替换成在上面链接中获得的密钥,例如ab-CDE_fghijkl0123456789
。
预认证密钥(另一种方法)
当然,还有另一种入网方法。
我们可以先在服务端上为设备预先创建好一个密钥,然后让客户端设备拿着密钥进行认证,这样就不需要从客户端上想办法获得确认口令,再去服务器上批准入网了。
在中央服务器上执行:
1 | docker exec headscale headscale preauthkeys create -u <username> -e 2h |
这里,-u <username>
仍然是用户名,而-e 2h
表示这条预认证密钥将在2小时后失效。因此在创建这条密钥之后,需要在2小时内到客户端上使用并完成认证。
没有意外的话,服务器会返回类似以下内容:
1 | 2025-01-11T12:00:00+08:00 TRC expiration has been set expiration=7200000 |
第二行就是生成的预认证密钥啦!让我们来到客户端,修改一下刚刚的入网命令:
1 | tailscale login --login-server https://myvlan.example.com --auth-key <yourkey> |
把<yourkey>
替换成上面的预认证密钥,执行命令,就可以成功入网,不需要再次回到中央服务器批准请求啦。
开心使用
组网之后,最基本的使用当然是利用分配的虚拟子网IP互相访问。不过如果仅限于此,似乎又差点意思。
MagicDNS
事实上这是我最喜欢的Tailscale/Headscale功能之一。
在使用Zerotier时,在设备间使用域名进行访问相当困难,似乎唯一可行的方法是在子网内搭建私有DNS服务器,手动维护DNS记录。而mDNS
这类基于广播的方案在Zerotier上支持也并不好。
但是,在Headscale上,一切就变得简单起来了!
回到先前的Headscale配置文件,如果我们定义了类似于以下内容:
1 | dns: |
假设在我们的房间里有一台设备的Hostname为myroom
的主机,那么我们就可以用myroom.my.lan
这个域名访问这台主机。在这个过程中,我们甚至不太需要和设备的虚拟子网IP地址打交道。
MagicDNS与多级域名
人总是贪心的,当MagicDNS提供了一个域名指向一台机器,就一定会有人希望能设置多个DNS记录指向同一台机器,从而根据不同域名分别访问不同的Web服务,类似于公网上的subdomain.example.com
。
设想一下,假设myroom
主机上同时运行了一个Gitlab实例和一个PhotoPrism实例。我们很自然地希望,在myroom
主机的80或443端口上设置一个Nginx服务器,分别监听前往git.myroom.my.lan
和pic.myroom.my.lan
域名的请求,再分别反向代理到对应服务端口上。这样可以免去记忆服务端口号的烦恼。
感谢MagicDNS,和在公网上类似,我们可以在Headscale内网中设置多条A
或AAAA
记录,指向不同的IP。
我们找到Headscale服务器配置中以下部分(在本文前面的配置文件示例中,注释已经写得很详细了):
1 | dns: |
根据目标地址的域名、IP分别修改一下:
1 | dns: |
保存配置文件,重启Docker,现在就可以在客户端上通过git.myroom.my.lan
域名访问地址为100.98.76.2
的主机了。
不过这种方案并不优雅,每次增删改DNS记录后都要重启Headscale实例。还好,我们可以进一步引入一个外置的DNS记录配置JSON文件避免这种尴尬。
创建一个dns-records.json
文件,用于和config.yaml
一同挂载到Docker容器内。
1 | hs-server/ |
在dns-records.json
中填入类似以下内容:
1 | [ |
再修改一下config.yaml
:
1 | dns: |
重启Headscale。在此之后,Headscale会在每次检测到dns-records.json
发生改动时,自动读取该文件并更新DNS记录。
DNS记录JSON文件格式问题
在headscale/docs/ref/dns.md中有这样一段话:
Be sure to "sort keys" and produce a stable output in case you generate the JSON file with a script. Headscale uses a checksum to detect changes to the file and a stable output avoids unnecessary processing.
似乎是说,在修改dns-records.json
文件时,要按一定的方式进行排序。暂时不是很清楚这一建议的含义。