Tailscale/Headscale自建异地组网

曾经用过一段时间的Zerotier作为异地组网/内网穿透的工具,用了一段时间感觉还行。

后来听说了Tailscale这个工具,再后来了解到了还有Headscale这样的开源实现,觉得这玩意似乎更加优雅,于是,折腾了半个晚上后总算把网络组起来了。在这里记录一下详细的过程。

术语

这类网络工具总是存在非常多的专有名词,所以得稍微先介绍一下:

Tailscale

Tailscale是一个基于WireGuard协议的组网工具。这一协议的好处是,在建立设备间通信时会尝试打洞,从而实现设备之间的P2P点对点通信,即便这些设备位于一些NAT网关后(不过根据NAT类型不同,打洞成功的可能性也不同,可参见:How NAT traversal works)。

Tailscale的工作流程不准确概括如下,如果希望更深入了解,可以阅读Tailscale: How it works

  1. Tailscale官方搭设了一系列中央服务器,为用户提供注册、通信建立与数据中继等功能。
  2. 用户在需要组网的设备上安装Tailscale客户端,登录账号并加入虚拟子网。
  3. 虚拟子网建立后,所有子网内设备在通信时,将先与Tailscale中央服务器进行通信,判断两个设备间能否直连(打洞)。
  4. 如果能打洞,直连(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,这点我们后面说。

技术细节

这篇文章采用的小技术细节:

  1. Headscale运行在Docker上,方便管理;
  2. Nginx实现反向代理;
  3. 提供Web UI管理Headscale服务器;
  4. 使用一个子域名如myvlan.example.com作为访问入口;
  5. 在已托管有好几个网站的服务器上搭设,因此如TLS证书、Nginx配置等都与现有其他网站兼容。

配置Headscale服务器

准备工作

显然,在开始配置自己的Headscale中央服务器之前,我们需要:

  • 一台具备公网IP的服务器(至少对你来说,这个IP地址应当确保在你的应用场景下,总是可达的)
  • 一个可用的域名(没有也能组网,但域名能省去非常多麻烦。同时,也假设已经为该域名配置好了HTTPS与证书)
  • Docker(直接在本机系统上设置也行,具体可以参考安装文档)
  • Nginx(如果你的服务器同时还运行着其他网站,需要充当反向代理的角色)

接下来让我们开始吧!

准备配置文件

首先我们需要前往Headscale的Github仓库,复制一份配置文件样板config-example.yaml

按需要修改配置文件,可以参考下面我的示例,一些无关紧要的项目注释已被删除,有需要可以参考上述完整的样板文件。

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
---
# Headscale服务器暴露在公网上的地址
# 该地址可被用于客户端与服务器的通信
server_url: https://myvlan.example.com

# Headscale服务器监听的端口
# 使用Docker可以直接写0.0.0.0:8080
# 若直接在本机系统上运行且8080端口被占用,可调整为其他空闲端口
listen_addr: 0.0.0.0:8080

# Headscale /metrics Web API
# 可用于获取Headscale运行状态
# 根据自身需求选择是否暴露该API
metrics_listen_addr: 127.0.0.1:9090

# Headscale gRPC 端口,用于CLI工具远程控制服务器
# 该功能需要配合证书使用
# 根据自身需求选择是否暴露该端口
grpc_listen_addr: 127.0.0.1:50443
grpc_allow_insecure: false

noise:
private_key_path: /var/lib/headscale/noise_private.key

# 设置Headscale分配的虚拟内网IP地址段
# 根据设计,Headscale要求内网IP段 **必须** 在以下子网范围内:
# - IPv4: 100.64.0.0/10
# - IPv6: fd7a:115c:a1e0::/48
# 请注意,这一功能与CGNAT冲突,见:https://tailscale.com/kb/1015/100.x-addresses
# 因此不能在如阿里云等启用了CGNAT的云服务器上安装 **Headscale客户端**
# 也就是不能将云服务器作为一台客户机加入VLAN。
prefixes:
v4: 100.98.76.0/24
v6: fd7a:115c:a1e0::/48

# IP分配策略,有`sequential`和`random`两种可选
allocation: sequential

## DERP配置
#
# 配置Headscale向客户机所分发的DERP服务器列表
# 这些DERP服务器可由3种不同方式定义配置
derp:
# 方法1:Headscale内置DERP服务器
server:
# 主开关,指定是否启用Headscale服务器内置的DERP服务器
# 启用后,可以将这台服务器主机作为转发中继流量的节点之一
# 由于DERP服务器强制要求TLS,因此如果启用这一选项,`server_url`必须以`https`开头
enabled: true

# 内置DERP服务器的Region ID
# 可以随便写,但要注意检查与其他DERP的Region ID冲突
# 如果冲突,内置DERP的Region ID会覆盖外部导入DERP
region_id: 999

# 随便写
region_code: "myoffice"
region_name: "DERP in My Office"

# 用于DERP服务器STUN连接的端口,可以自定义
# 需要前往云服务器防火墙设置中放行对应端口的UDP链接
stun_listen_addr: "0.0.0.0:10086"

private_key_path: /var/lib/headscale/derp_server_private.key
automatically_add_embedded_derp_region: true

# 填写云服务器的IP地址
ipv4: 1.2.3.4
ipv6: 2001:db8::1

# 方法2:可订阅的JSON格式响应API,获取一系列DERP服务器列表
# 在示例配置文件中,默认包含了一条从Tailscale服务器上拉取DERPMAP的URL
# 如果想完全禁用Tailscale提供的服务器,请直接将这一段改成:`urls: []`
urls:
- https://controlplane.tailscale.com/derpmap/default

# 方法3:本地DERP YAML文件定义
# 指定所使用的DERP定义YAML文件绝对路径
# 示例:
# paths:
# - /etc/headscale/myderp.yaml
paths: []

auto_update_enabled: true
update_frequency: 24h

disable_check_updates: true
ephemeral_node_inactivity_timeout: 30m

database:
type: sqlite
debug: false
gorm:
prepare_stmt: true
parameterized_queries: true
skip_err_record_not_found: true
slow_threshold: 1000
sqlite:
path: /var/lib/headscale/db.sqlite
write_ahead_log: true
wal_autocheckpoint: 1000

## TLS配置
#
# 因为我的服务器上已配置好HTTPS,且我希望由Nginx反向代理管理HTTPS连接
# 因此本配置文件将这部分内容置空,令Headscale服务器跳过自动配置TLS
# 如果有需要,请参考官方提供的完整示例文件
acme_url: https://acme-v02.api.letsencrypt.org/directory
acme_email: ""
tls_letsencrypt_hostname: ""
tls_letsencrypt_cache_dir: /var/lib/headscale/cache
tls_letsencrypt_challenge_type: HTTP-01
tls_letsencrypt_listen: ":http"
tls_cert_path: ""
tls_key_path: ""

log:
format: text
level: info

policy:
mode: file
path: ""

## DNS配置
#
# Headscale可支持Talescale的DNS配置以及MagicDNS,具体可参考文档:
# - https://tailscale.com/kb/1054/dns/
# - https://tailscale.com/kb/1081/magicdns/
# - https://tailscale.com/blog/2021-09-private-dns-with-magicdns/
# - https://tailscale.com/kb/1235/resolv-conf
# 如果不希望Headscale/Tailscale管理DNS,请将以下所有子项目值置空
dns:
# MagicDNS主开关
# 我个人挺喜欢的一项功能,可以自动为每台加入虚拟局域网的主机分配一个DNS记录
# 如果不需要,或在复杂网络配置下担心扰乱现有网络配置,请关闭这一选项
magic_dns: true

# MagicDNS基础域名(二级虚拟域名),附加在主机名后作为DNS记录
# 这一名称不可以和`server_url`使用的域名相同
# 建议可以使用如`.lan`等目前IANA没有分配的顶级域名,不干扰其他正常域名解析
# 例如,`base_domain`取值为`my.lan`,则可以用`myroom.my.lan`指代网络中名为`myroom`的主机
base_domain: my.lan

# 向客户端暴论的DNS列表,要求客户端使用这些DNS进行解析
# 这一选项在公司等网络中非常有用
nameservers:
global:
- 114.114.114.114
- 223.5.5.5
- 8.8.8.8
- 2400:3200::1
- 2001:4860:4860::8888

# Split DNS (参见:https://tailscale.com/kb/1054/dns/),
split:
{}
# foo.bar.com:
# - 1.1.1.1
# darp.headscale.net:
# - 1.1.1.1
# - 8.8.8.8

search_domains: []

# 设置Headscale服务器上额外的DNS记录
# 目前只支持A和AAAA记录
# 参见:docs/ref/dns.md
extra_records: []
# - name: "git.myroom.my.lan"
# type: "A"
# value: "100.98.76.2"
#
# # 也可以写在一行里
# - { name: "git.myroom.my.lan", type: "A", value: "100.98.76.2" }
#
# 也可以从一个外部JSON文件导入,该文件每次被更改时会被自动解析加载
# extra_records_path: /var/lib/headscale/extra-records.json

# Unix socket可以让CLI不需要认证就能连接到服务器控制程序,直接抄就行
unix_socket: /var/run/headscale/headscale.sock
unix_socket_permission: "0770"

logtail:
enabled: false

randomize_client_port: false

写好以后,保存,文件名为config.yaml

准备Docker

我们新建一个文件夹,用于放置我们的配置文件等等,假设就叫hs-server吧。

在里面新建一个文件夹,并把刚刚我们编写好的config.yaml放进来,假设就叫hs-etc吧,这个文件稍后会被映射到容器的/etc/headscale文件夹。

接下来我们要使用Docker Compose创建容器,我们回到hs-server文件夹,新建docker-compose.yml文件如下。

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
services:
headscale:
image: headscale/headscale
restart: unless-stopped
container_name: headscale
ports:
# Headscale服务器主端口,如果不打算使用反向代理可以去掉前面的`127.0.0.1`
- "127.0.0.1:10080:8080"
# /metrics API端口,根据需要开放(在前面Headscale配置里需要开放外部监听)
# - "127.0.0.1:10089:9090"
# DERP STUN,如果启用了内置DERP服务器,请在这里映射对应端口
# - 10086:10086
# - 10086:10086/udp
volumes:
# 配置文件文件夹
- ./hs-etc/:/etc/headscale
# 用一个卷volume存放Headscale的数据库等
- hs-data:/var/lib/headscale
# 将本机系统时间与时区等映射到容器内
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
command: serve

volumes:
hs-data:

完成后,保存。

现在我们的文件夹hs-server下应该包含以下内容:

1
2
3
4
5
6
hs-server/
├── docker-compose.yml
└── hs-etc
└── config.yaml

2 directories, 2 files

启动容器

Docker,启动!

1
docker compose up -d

可以curl访问一下刚刚映射的主端口,检查Headscale是否正常启动。默认访问/结点结果是404,所以记得用-D -输出响应头检查确认。

1
2
3
4
user@MyServer:~$ curl -D - http://127.0.0.1:10080
HTTP/1.1 404 Not Found
Date: Sun, 11 Jan 2025 12:24:22 GMT
Content-Length: 0

开放防火墙(可选)

如果配置了DERP服务器,请务必记得在系统防火墙与云服务商控制面板中放行对应端口,例如上面示例配置文件中的10086端口的UDP通信。

配置Nginx反向代理

不过,到目前为止,我们搭建的Headscale中央服务器还没有映射到我们的子域名,如myvlan.example.com上。

而且,还记得我们前面提到,最好为服务器启用TLS加密吗?在这一套实现中,这相关的工作也要交给Nginx完成。

这里放上Nginx的示例配置文件,按需修改其中使用的子域名、SSL证书路径、反向代理Headscale服务器地址等参数后,就可以和其他Nginx配置一起使用了。

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
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}

server {
listen 443 ssl http2;
listen [::]:443 ssl http2;

server_name myvlan.example.com;

index index.html index.htm;

ssl_certificate /path/to/your/cert/fullchain.pem;
ssl_certificate_key /path/to/your/cert/privkey.pem;

ssl_dhparam /path/to/your/dhparam.pem;
ssl_prefer_server_ciphers on;
ssl_session_timeout 10m;
ssl_session_cache shared:SSL:10m;
ssl_session_tickets off;

add_header X-Frame-Options DENY;
add_header X-Content-Type-Options nosniff;
add_header X-XSS-Protection "1; mode=block";

location / {
#client_max_body_size 512M;
# 这里填写Headscale中央服务器的本地监听端口
proxy_pass http://127.0.0.1:10080;
#proxy_set_header Connection $http_connection;
#proxy_set_header Upgrade $http_upgrade;
#proxy_set_header Host $host;
#proxy_set_header X-Real-IP $remote_addr;
#proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
#proxy_set_header X-Forwarded-Proto $scheme;

proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $server_name;
proxy_redirect http:// https://;
proxy_buffering off;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
add_header Strict-Transport-Security "max-age=15552000; includeSubDomains" always;
}
}

server {
listen 80;
listen [::]:80;

server_name myvlan.example.com;

if ($host = myvlan.example.com) {
return 301 https://$host$request_uri;
}

return 404;
}

重启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
2
3
To authenticate, visit:

https://myvlan.example.com/register/ab-CDE_fghijkl0123456789

可以继续访问上面的链接,网页会告诉我们,需要前往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
2
2025-01-11T12:00:00+08:00 TRC expiration has been set expiration=7200000
abcdefghijklmnopqrstuvwxyz0123456789012345678901

第二行就是生成的预认证密钥啦!让我们来到客户端,修改一下刚刚的入网命令:

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
2
3
dns:
magic_dns: true
base_domain: my.lan

假设在我们的房间里有一台设备的Hostname为myroom的主机,那么我们就可以用myroom.my.lan这个域名访问这台主机。在这个过程中,我们甚至不太需要和设备的虚拟子网IP地址打交道。

MagicDNS与多级域名

人总是贪心的,当MagicDNS提供了一个域名指向一台机器,就一定会有人希望能设置多个DNS记录指向同一台机器,从而根据不同域名分别访问不同的Web服务,类似于公网上的subdomain.example.com

设想一下,假设myroom主机上同时运行了一个Gitlab实例和一个PhotoPrism实例。我们很自然地希望,在myroom主机的80或443端口上设置一个Nginx服务器,分别监听前往git.myroom.my.lanpic.myroom.my.lan域名的请求,再分别反向代理到对应服务端口上。这样可以免去记忆服务端口号的烦恼。

感谢MagicDNS,和在公网上类似,我们可以在Headscale内网中设置多条AAAAA记录,指向不同的IP。

我们找到Headscale服务器配置中以下部分(在本文前面的配置文件示例中,注释已经写得很详细了):

1
2
dns:
extra_records: []

根据目标地址的域名、IP分别修改一下:

1
2
3
4
5
dns:
extra_records:
- name: "git.myroom.my.lan"
type: "A"
value: "100.98.76.2"

保存配置文件,重启Docker,现在就可以在客户端上通过git.myroom.my.lan域名访问地址为100.98.76.2的主机了。

不过这种方案并不优雅,每次增删改DNS记录后都要重启Headscale实例。还好,我们可以进一步引入一个外置的DNS记录配置JSON文件避免这种尴尬。

创建一个dns-records.json文件,用于和config.yaml一同挂载到Docker容器内。

1
2
3
4
5
6
7
hs-server/
├── docker-compose.yml
└── hs-etc
├── config.yaml
└── dns-records.json

2 directories, 3 files

dns-records.json中填入类似以下内容:

1
2
3
4
5
6
7
[
{
"name": "git.myroom.my.lan",
"type": "A",
"value": "100.98.76.2"
}
]

再修改一下config.yaml

1
2
dns:
extra_records_path: /etc/headscale/dns-records.json

重启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文件时,要按一定的方式进行排序。暂时不是很清楚这一建议的含义。