【白折腾】部署headscale实现虚拟组网,不如直接用tailscale

参考教程如下,感谢作者的付出:
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.3tag,仓库中不存在latest这个默认tag
  • 域名SSL签名

准备

部署服务

目录结构

  • headscale/
    • config/
      • config.yaml
    • data/
      • db.sqlite
    • docker-compose.yml

创建数据库

touch ./config/db.sqlite

下载调整config示例

下载config

因为文件修改地方较多,最好是在电脑上下载配置文件,修改后再上传到服务器
官方目前的仍然在测试0.23.0-beta版本,默认的配置文件略有变动,为了稳妥起见,还是使用0.22.3对应的示例文件:v0.22.3

调整配置

配置文件中已经有详细的注释,为了使用顺利,最好根据需求调整。下面是一些我的调整项,可供参考:

  1. 修改访问域名:
    • server_url
    • 将 IP+端口 替换为 域名
    • 启用derp必须使用HTTPS,并有ssl证书
  2. 修改访问IP:
    • listen_addr metrics_listen_addr grpc_listen_addr
    • 127.0.0.1 替换为 0.0.0.0
    • 直接取消注释就行,仔细看上方有替换项
  3. 调整ipv4默认网段(调整了它就说范围不支持,还是别改了)
    • ip_prefixes
    • 10.255.0.0/10
  4. 开启derp(开了好像也没啥用)
derp:
  server:
    enabled: true
  1. 更换DNS(关闭覆盖也行)
dns_config:
  nameservers:
    - 114.114.114.114
    - 114.114.115.115
  1. 关闭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面板一起配置:

  1. 首先检查8080、9090、9443、9080端口是否被占用,或者根据喜欢更换其他端口:
netstat -tuln | grep <端口号>
  1. 因为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

  1. 通过ssh访问云服务器,创建apikey:
docker exec headscale headscale api create
  1. 进入Server Settings,填入apikey。apikey 只会显示这一次,但是 ui 界面填入的内容只保存在该浏览器的本地缓存中,所以请妥善保管好您的 apikey,或者干脆每次申请一个新的。

命令

实际上直接使用UI操作就行了,点点鼠标的事,没必要输命令

  1. 创建用户
docker exec headscale headscale user create <USERNAME>
  1. 生成 Authkey
    <TIME> 指有效期长度,一般最好不要超过 24h
    --reusable 意味着这个 Authkey 可被复用
docker exec headscale headscale preauthkeys create -e <TIME> --user <USERNAME> --reusable
  1. 查看注册节点
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来认证是没问题的。

结局

懒得折腾了

20 个赞

tailscale 确实好点

2 个赞

当年折腾 headscale 的那台服务器都释放了,现在也直接 tailscale

2 个赞

思来想去根本没有折腾的必要,用官方的又稳又好,反正就是登录验证罢了

大佬很细啊

2 个赞

等我服务器到期就迁回官方 :tieba_009:

不能白折腾,headscale先凑合用着

2 个赞

踩了俩星期坑,能不细嘛

详细!很棒

1 个赞

我之前也折腾了好久这玩意,翻了翻之前的文档,应该用的0.23.0-alpha。然而现在主要还是ipv6,这东西就拿来当个备用了 :rofl:

2 个赞

我是直接用tailscale,以前以为用户层实现的wg会慢,结果发现跟我用wireguard延迟没差多少

1 个赞

最开始用的Zerotier,后来也折腾过Headscale和Zerotier planet,都太麻烦了。现在还是在用Zerotier官方服务,很多服务换来换去也没必要。
这几个好像也不错:
https://github.com/EasyTier/EasyTier
https://github.com/vnt-dev/vnt
https://github.com/ntop/n2n

1 个赞

直接用tailscale?还需要自建中转节点吗?

没必要,走中转怎么都慢,除非你有大水管的服务器,不然就靠ipv6直连,或者穿透

这些都不如tailscale高效,Zerotier是自己的协议,不如wireguard,tailscale是在用户层实现了wireguard,几乎没有损失,你试试就知道

很难不支持,其实墙内搞个derper服务,双NAT4都能打洞成功,跨网亲测过

1 个赞

是吗,你这么说搞得我有点心动,但是SSL认证是真的让我有点烦了

我这有台服务器A定期会失联,客户端A需要重启下ta才能连上服务器A,我以为问题出在客户端A,但实际客户端A不重启能长期连上服务器B~就是连服务器A要重启下 :rofl:
而且服务器A是固定公网IP,排除了因为变IP问题导致的失联~

纯IP部署derp啊,自签证书可以的,瞎逼签都行,我都是脸滚键盘的假域名

1 个赞

一次性防火墙?你可以试着ssh保活,看看还会不会掉线

我看教程说连derp需要验证SSL证书,derp需要验证,客户端也要验证