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
文件时,要按一定的方式进行排序。暂时不是很清楚这一建议的含义。
(附赠)自签名HTTPS证书
搭建起Headscale后,一种常见用途就是访问内网其他设备的Web服务。在Tailscale/Headscale内网中,所有数据包都是加密后发送的,理论上讲其实并不会因为使用HTTP直接访问造成安全问题。
但是,一部分Web应用要求必须使用安全连接,因此通常又有使用HTTPS的需求。
然而,显然,没有任何第三方CA会给我们的内网域名颁发TLS证书。考虑到内网中的设备其实都是我们自己使用的,此时,自签名证书就成了最方便灵活的选择。
一般的自签名证书只对应于一组域名,在有多个设备多个域名的场景下,相比起重复颁发并在客户端上信任多个证书,或是顶着浏览器的不安全警告继续访问,或许我们其实可以采用类似于第三方CA的做法。
具体来说,我们的思路是:
- 先签发一个根证书(Root CA Certificate),此时我们就扮演了Root CA的角色。
- 使用根证书,为需要信任的域名(比方说
myroom.my.lan
)颁发一个新的证书(本文称为子证书)。 - 子证书 安装到提供该Web服务的服务器上,并配置使用如Nginx等提供HTTPS服务。
- 在客户端(也就是访问Web服务的终端)上安装根证书,此时客户端将信任所有基于该根证书签发的子证书。
- 由此,可以在客户端上愉快地内网HTTPS服务。
- 在未来需要为新的域名颁发证书(例如
another.my.lan
)时,只需要执行2~3步骤,不再需要在客户端上安装新证书,因为根证书已经被信任了。
接下来是具体步骤,这些步骤需要使用OpenSSL执行。除特别说明外,这些证书与密钥的存储位置与文件名都是比较随意的,只需要考虑合适的文件权限与维护的便利即可。
签发Root CA根证书
我们首先需要做好扮演Root CA的任务,有了根证书以后才能基于该证书签发新的证书。
第一步,生成Root
CA的私钥rootca.key
。该文件需要妥善保管,不能外泄。(其实泄了也没啥,反正是跑在虚拟内网上的。)
下面命令中末尾的4096
是RSA密钥长度。都2025年了,别再用2048位的RSA了。
1 | openssl genrsa -out rootca.key 4096 |
证书加密签名算法的问题
一定有小伙伴想,RSA都是多久以前的加密算法了,要不我们换一点新玩意,比方说ECDSA,EdDSA之类的(其实这些算法地位上似乎并不能并列)。
事实上,ECDSA已经开始用于一些网站中。但是EdDSA几乎没有得到支持。而且,EdDSA的证书在Windows 10中无法被正确识别。
考虑到ECDSA是NSA弄出来的玩意,要不,我们还是用更长的RSA吧。
第二步,准备一个Root
CA证书生成配置文件rootca.conf
。该文件主要是提供证书中包含的信息,如别名等。
在这个配置文件中,[req_distinguished_name]
段的内容可以随意更改。自签名证书嘛,随便填,好玩就行。
1 | [req] |
第三步,使用上面的私钥和配置文件,接下来我们就可以生成Root
CA的根证书文件rootca.crt
了。
这个根证书文件rootca.crt
,就是接下来可安装到各客户端上的证书。
1 | openssl req -x509 -new -key rootca.key -days 3650 -sha512 -config rootca.conf -out rootca.crt |
顺带一提,如果不希望使用配置文件rootca.conf
控制证书信息,也可以去掉-config rootca.conf
这一参数。OpenSSL会启动一个问答,根据提示填写即可。
签发子证书
假设我们希望给myroom.my.lan
这台服务器签发一个通配符证书。该证书既信任myroom.my.lan
域名,也同时信任*.myroom.my.lan
这一泛域名,日后我们使用pic.myroom.my.lan
这样的域名访问该服务器时,就不再需要签发新证书了。
首先,我们需要为新的子证书生成一个私钥myroom.key
。原则上讲,该私钥不需要透露给Root
CA。不过现在只有我们一个人,这些都无所谓啦。
1 | openssl genrsa -out myroom.key 4096 |
第二步,编写子证书的证书请求配置文件myroom.conf
。和前面一样,[req_distinguished_name]
块的内容可以随意配置,但CN
也就是Common
Name部分需要填写为证书对应的域名。
同时,如果希望该证书同时信任其他域名,只需要在[alt_names]
块部分按格式继续添加即可。注意,myserver.lan
和*.myserver.lan
是不同的域名,需要分别列出!
1 | [req] |
第三步,接下来需要生成子证书的证书请求(Certificate
Request)文件myroom.csr
。在Root
CA和子证书请求分离的场景下,证书请求是可以发送给Root
CA以申请证书的。
1 | openssl req -new -key myroom.key -config myroom.conf -out myroom.csr |
第四步,Root CA收到了证书请求myroom.csr
后,利用Root
CA自己的私钥,以及根证书和配置文件,最终生成子证书myroom.crt
。
1 | openssl x509 -req -in myroom.csr -CA rootca.crt -CAkey rootca.key |
到此为止,所有需要的证书、密钥文件都已经生成。只需要按需部署即可。
Nginx绑定证书
使用Nginx提供HTTPS时,需要使用上面子证书签发中获得的myroom.crt
证书文件与myroom.key
密钥文件。Nginx配置的重点在于以下两句,后缀名是不重要的。
1 | ssl_certificate /path/to/self-signed/myroom.crt; |
将根证书安装到Windows
- 找到刚生成的Root CA根证书
rootca.crt
- 双击打开后点击安装证书(在资源管理器右键“安装证书”也行)
- 存储位置选择当前用户
- 接下来选将所有的证书都放入下列存储,点击右侧浏览,选择受信任的根证书颁发机构
- 之后一路确定即可。
将根证书安装到Linux
- 找到刚生成的Root CA根证书
rootca.crt
- 将其复制到
/usr/local/share/ca-certificates/
目录下。需要注意,文件名随意但后缀名必须为.crt
。以上命令生成的.crt
证书格式就是可以被正常识读的,不需要再做转换。 - 执行
sudo update-ca-certificates
更新系统证书缓存。如果报错发现刚刚添加的证书被跳过了,可以试着加--fresh
参数执行。
顺带一提,Linux的Firefox浏览器不依赖系统的证书库,而是使用自建的证书库。因此在Linux上使用Firefox时,需要手动前往设置,在隐私与安全部分,将根证书导入到Authorities列表中。