参考教程如下,感谢作者的付出:
Docker 搭建 headscale 异地组网完整教程
Docker 搭建中继服务器 derp - 纯 IP 实现
Docker 搭建中继服务器 derp - 需要域名并配置 ssl
TailScale+Headscale 局域网组网全记录
headscale是tailscale控制台的开源版本,就是负责账户登录、授权、管理等,默认仍然是使用官方的 DERP 服务器来中继流量,当 Tailscale 节点之间无法直接建立 P2P 连接时,就会通过中继服务器来中转流量。如果你的网络环境优秀,或者对带宽要求不高,那么大可不必自建headscale,直接使用tailscale即可,免费用户能添加100个设备,用得完就有鬼了。
假如有一台国内的服务器,最好还是自建derp,官方的derp节点遇到高峰期挤占带宽会变得很慢,如果打洞失败使用derp中继,实测移动数据下载速度只有32kbps,体验极差。
有一些教程会选择使用外置的Docker部署derp服务,我认为个人使用不需要考虑灵活性和定制化,使用headscale的配置文件中自带的内嵌式derp即可。derp 服务器和tailscale客户端都是会验证域名的,需要准备域名和ssl证书。纯IP搭建我看教程太麻烦了,放弃了,如果你有需求,可以参考Docker 搭建中继服务器 derp - 纯 IP 实现
但是经过我一两个星期的折腾,发现真不如直接使用tailscale,然后把ipv6打开,毕竟国内服务器延迟低,但是带宽小水管,没有意义,不如直接公网ipv6穿透,反正tailscale智能穿透,你无需关心它的实现。
总之下面就算是我的折腾记录吧,留个念想,虽然我最终也没有用上,但是过程中还是解决了一些坑,对需要的人大抵是有用的。
提示
- 文件夹结构具体参考配置文件,config文件路径需对应镜像内的
/etc/headscale/
,数据库、key等文件路径对应/var/lib/headscale/
- 拉取Docker镜像
headscale/headscale
时,直接使用0.22.3
tag,仓库中不存在latest
这个默认tag - 域名SSL签名
准备
- 公网IP的国内云服务器
- 域名(Let’s encrypt实现ssl证书签名)
- headscale
- headscale-ui
部署服务
目录结构
- headscale/
- config/
- config.yaml
- data/
- db.sqlite
- docker-compose.yml
- config/
创建数据库
touch ./config/db.sqlite
下载调整config示例
下载config
因为文件修改地方较多,最好是在电脑上下载配置文件,修改后再上传到服务器
官方目前的仍然在测试0.23.0-beta版本,默认的配置文件略有变动,为了稳妥起见,还是使用0.22.3对应的示例文件:v0.22.3
调整配置
配置文件中已经有详细的注释,为了使用顺利,最好根据需求调整。下面是一些我的调整项,可供参考:
- 修改访问域名:
server_url
- 将 IP+端口 替换为 域名
- 启用derp必须使用HTTPS,并有ssl证书
- 修改访问IP:
listen_addr
metrics_listen_addr
grpc_listen_addr
- 将
127.0.0.1
替换为0.0.0.0
- 直接取消注释就行,仔细看上方有替换项
- 调整ipv4默认网段(调整了它就说范围不支持,还是别改了)
ip_prefixes
10.255.0.0/10
- 开启derp(开了好像也没啥用)
derp:
server:
enabled: true
- 更换DNS(关闭覆盖也行)
dns_config:
nameservers:
- 114.114.114.114
- 114.114.115.115
- 关闭magicDNS(自动分配子域名,我用不上)
magic_dns: false
下载镜像
CF加速
docker镜像无法直接获取,可以通过cloudflare的worker服务来加速,代码是我从论坛上大佬那扒来的,感谢:
记得把【域名】改成你自己的,一般用docker.子域名比较好记
'use strict'
const hub_host = 'registry-1.docker.io'
const auth_url = 'https://auth.docker.io'
const workers_url = 'https://【域名】'
/**
* static files (404.html, sw.js, conf.js)
*/
/** @type {RequestInit} */
const PREFLIGHT_INIT = {
status: 204,
headers: new Headers({
'access-control-allow-origin': '*',
'access-control-allow-methods': 'GET,POST,PUT,PATCH,TRACE,DELETE,HEAD,OPTIONS',
'access-control-max-age': '1728000',
}),
}
/**
* @param {any} body
* @param {number} status
* @param {Object<string, string>} headers
*/
function makeRes(body, status = 200, headers = {}) {
headers['access-control-allow-origin'] = '*'
return new Response(body, {status, headers})
}
/**
* @param {string} urlStr
*/
function newUrl(urlStr) {
try {
return new URL(urlStr)
} catch (err) {
return null
}
}
addEventListener('fetch', e => {
const ret = fetchHandler(e)
.catch(err => makeRes('cfworker error:\n' + err.stack, 502))
e.respondWith(ret)
})
/**
* @param {FetchEvent} e
*/
async function fetchHandler(e) {
const getReqHeader = (key) => e.request.headers.get(key);
let url = new URL(e.request.url);
if (url.pathname === '/token') {
let token_parameter = {
headers: {
'Host': 'auth.docker.io',
'User-Agent': getReqHeader("User-Agent"),
'Accept': getReqHeader("Accept"),
'Accept-Language': getReqHeader("Accept-Language"),
'Accept-Encoding': getReqHeader("Accept-Encoding"),
'Connection': 'keep-alive',
'Cache-Control': 'max-age=0'
}
};
let token_url = auth_url + url.pathname + url.search
return fetch(new Request(token_url, e.request), token_parameter)
}
url.hostname = hub_host;
let parameter = {
headers: {
'Host': hub_host,
'User-Agent': getReqHeader("User-Agent"),
'Accept': getReqHeader("Accept"),
'Accept-Language': getReqHeader("Accept-Language"),
'Accept-Encoding': getReqHeader("Accept-Encoding"),
'Connection': 'keep-alive',
'Cache-Control': 'max-age=0'
},
cacheTtl: 3600
};
if (e.request.headers.has("Authorization")) {
parameter.headers.Authorization = getReqHeader("Authorization");
}
let original_response = await fetch(new Request(url, e.request), parameter)
let original_response_clone = original_response.clone();
let original_text = original_response_clone.body;
let response_headers = original_response.headers;
let new_response_headers = new Headers(response_headers);
let status = original_response.status;
if (new_response_headers.get("Www-Authenticate")) {
let auth = new_response_headers.get("Www-Authenticate");
let re = new RegExp(auth_url, 'g');
new_response_headers.set("Www-Authenticate", response_headers.get("Www-Authenticate").replace(re, workers_url));
}
if (new_response_headers.get("Location")) {
return httpHandler(e.request, new_response_headers.get("Location"))
}
let response = new Response(original_text, {
status,
headers: new_response_headers
})
return response;
}
/**
* @param {Request} req
* @param {string} pathname
*/
function httpHandler(req, pathname) {
const reqHdrRaw = req.headers
// preflight
if (req.method === 'OPTIONS' &&
reqHdrRaw.has('access-control-request-headers')
) {
return new Response(null, PREFLIGHT_INIT)
}
let rawLen = ''
const reqHdrNew = new Headers(reqHdrRaw)
const refer = reqHdrNew.get('referer')
let urlStr = pathname
const urlObj = newUrl(urlStr)
/** @type {RequestInit} */
const reqInit = {
method: req.method,
headers: reqHdrNew,
redirect: 'follow',
body: req.body
}
return proxy(urlObj, reqInit, rawLen, 0)
}
/**
*
* @param {URL} urlObj
* @param {RequestInit} reqInit
*/
async function proxy(urlObj, reqInit, rawLen) {
const res = await fetch(urlObj.href, reqInit)
const resHdrOld = res.headers
const resHdrNew = new Headers(resHdrOld)
// verify
if (rawLen) {
const newLen = resHdrOld.get('content-length') || ''
const badLen = (rawLen !== newLen)
if (badLen) {
return makeRes(res.body, 400, {
'--error': `bad len: ${newLen}, except: ${rawLen}`,
'access-control-expose-headers': '--error',
})
}
}
const status = res.status
resHdrNew.set('access-control-expose-headers', '*')
resHdrNew.set('access-control-allow-origin', '*')
resHdrNew.set('Cache-Control', 'max-age=1500')
resHdrNew.delete('content-security-policy')
resHdrNew.delete('content-security-policy-report-only')
resHdrNew.delete('clear-site-data')
return new Response(res.body, {
status,
headers: resHdrNew
})
}
部署完成后,在设置-触发器-自定义域,加上域名
使用时在pull的镜像地址前加上域名/
即可
拉取改名
这里我们需要用到两个镜像,只有一个需要加速
- headscale-ui 可以直接拉取
docker pull ghcr.io/gurucomputing/headscale-ui
- 通过cf加速拉取
docker pull 域名/ifargle/headscale-webui
拉取后的镜像名带上了域名前缀,为了方便管理,给它改名去掉:
docker tag 域名/headscale/headscale:0.22.3 headscale/headscale:0.22.3
配置compose
此处将headscale和UI面板一起配置:
- 首先检查8080、9090、9443、9080端口是否被占用,或者根据喜欢更换其他端口:
netstat -tuln | grep <端口号>
- 因为headscale-ui默认使用80、443端口,docker默认权限下无法正常绑定,需要使用环境变量定义到1024以上的端口,然后再映射到容器外部issues/14: Docker container port:
version: '3.5'
services:
headscale:
image: headscale/headscale:0.22.3
container_name: headscale
command: headscale serve
restart: unless-stopped
volumes:
- ./headscale/config:/etc/headscale/
- ./headscale/data:/var/lib/headscale/
ports:
- "8080:8080"
- "9090:9090"
headscale-ui:
image: ghcr.io/gurucomputing/headscale-ui:latest
restart: unless-stopped
container_name: headscale-ui
environment:
- HTTP_PORT=9080
- HTTPS_PORT=9443
ports:
- "9080:9080"
- "9443:9443"
配置nginx
通过访问域名/admin
的方式,即可跳转至UI面板
# headscale
server {
server_name 【域名】;
# Security / XSS Mitigation Headers
add_header X-Frame-Options "SAMEORIGIN";
add_header X-XSS-Protection "1; mode=block";
add_header X-Content-Type-Options "nosniff";
add_header Strict-Transport-Security "max-age=15552000; includeSubDomains" always;
location /web {
proxy_redirect off;
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_pass http://127.0.0.1:9080;
}
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
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;
}
listen 443 ssl;
listen [::]:443 ssl;
ssl_certificate /fullchain.pem;
ssl_certificate_key /.key;
}
server {
listen 80;
server_name 【域名】;
rewrite ^(.*)$ https://$host$1 permanent;
}
测试使用
UI配置
容器启动成功,可以通过访问 域名/web 来进入管理界面,首次使用需要设置apikey
- 通过ssh访问云服务器,创建apikey:
docker exec headscale headscale api create
- 进入Server Settings,填入apikey。apikey 只会显示这一次,但是 ui 界面填入的内容只保存在该浏览器的本地缓存中,所以请妥善保管好您的 apikey,或者干脆每次申请一个新的。
命令
实际上直接使用UI操作就行了,点点鼠标的事,没必要输命令
- 创建用户
docker exec headscale headscale user create <USERNAME>
- 生成 Authkey
<TIME>
指有效期长度,一般最好不要超过24h
--reusable
意味着这个 Authkey 可被复用
docker exec headscale headscale preauthkeys create -e <TIME> --user <USERNAME> --reusable
- 查看注册节点
docker exec headscale headscale nodes list
客户端接入
这个才是重头戏,不能正常认证使用,等于白搭
Windows
访问 域名/windows
可以查看接入指南
IOS、Mac OS
访问 域名/apple
可以查看接入指南
Android
官方应用已经支持自定义登录地址,右上角设置-账户-右上角三个点-use an alternate Server
群晖
手动安装spk,或者在套件中心搜索安装
然后通过ssh执行以下命令即可,根据提示完成认证
sudo tailscale up --login-server=域名 --accept-dns=false --reset --force-reauth
如果想恢复正常认证,通过以下命令即可
sudo tailscale up --accept-dns=false --reset --force-reauth
无法认证
我运气不好,只成功连接过一次,便再也无法使用。
Windows系统中,在C:\ProgramData\Tailscale
路径下有日志文件,可以根据这个来排除错误。
找到以下异常信息
2024-08-01T14:10:20.378+08:00: Received error: fetch control key: Get "https://【域名】/key?v=102": read tcp 192.168.31.125:13104->【IP】:443: wsarecv: An existing connection was forcibly closed by the remote host.
- 验证域名解析
在PowerShell中测试,验证域名解析,根据上面的日志其实可以判断解析是正常的。
nslookup 【域名】
- 检查 SSL 证书
在PowerShell中测试 SSL 证书
openssl s_client -connect 【域名】:443
果然出问题了
CONNECTED(000001C8)
write:errno=10054
---
no peer certificate available
---
No client certificate CA names sent
---
SSL handshake has read 0 bytes and written 325 bytes
Verification: OK
---
New, (NONE), Cipher is (NONE)
Secure Renegotiation IS NOT supported
Compression: NONE
Expansion: NONE
No ALPN negotiated
Early data was not sent
Verify return code: 0 (ok)
---
连接被强制关闭 (write:errno=10054
),并且没有读取到任何字节。这意味着 SSL 握手失败,可能是由于没有可用的对等证书
- 直接测试IP:443
openssl s_client -connect [IP]:443
证书正常,此时直接使用IP来认证是没问题的。
结局
懒得折腾了