tailscale比较好用,而且免费用户福利也没有什么大幅度缩水,由于国内网络和国外不互通,所以官方的DERP服务并不好用,有必要自建一个国内的私有DERP给自己用,几年前我就建过一个,最近整理服务器迁移事宜,所以写了一个更详尽的文档记录一下

官方文档写的并不是很详细,但是还是有一定的参考价值,https://tailscale.com/kb/1118/custom-derp-servers ,我的思路是给STUN和HTTPS都设置了不知名的特殊端口,手动管理了TLS证书,所以并不需要开放80端口用于自动化申请证书
同时,我采用了URL验证客户端的方案,防止有人白嫖….

整体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
26
27
28
services:
derper:
image: fredliang/derper:208878d628c7c6cad604da7798b6deee3894c7a6
container_name: derper
environment:
- DERP_DOMAIN=xx.domain.com
- DERP_CERT_DIR=/etc/letsencrypt/live/domain.com
- DERP_CERT_MODE=manual
- DERP_ADDR=:port
- DERP_STUN=true
- DERP_STUN_PORT=stun_port
- DERP_HTTP_PORT=-1
- DERP_VERIFY_CLIENT_URL=http://verify_client:3000
ports:
- "port:port"
- "stun_port:stun_port/udp"
volumes:
- /etc/letsencrypt/live/domain.com:/etc/letsencrypt/live/domain.com:ro
restart: unless-stopped

verify_client:
build:
context: .
dockerfile: Dockerfile
container_name: verify_client
volumes:
- ./nodes.json:/app/nodes.json:ro
restart: unless-stopped

此处使用了Github项目,免得自己构建docker镜像,此处TLS的证书,我是使用了certbot申请了泛域名证书,得到的文件名并不能直接被derper使用,因此有一个小脚本cp到了适合的文件名
update_cert_and_restart_docker.sh

1
2
3
4
5
6
7
8
#!/bin/bash

# 复制证书文件
cp /etc/letsencrypt/live/domain.com/fullchain.pem /etc/letsencrypt/live/domain.com/xx.domain.com.crt
cp /etc/letsencrypt/live/domain.com/privkey.pem /etc/letsencrypt/live/domain.com/xx.domain.com.key

# 重建并重启 Docker 容器
/usr/bin/docker compose -f /root/derper/docker-compose.yml up --build --force-recreate -d

另外加入了cron定时任务,让它每半个月重启一次,以使用新证书

1
0 2 1,15 * * /root/derper/update_cert_and_restart_docker.sh

防白嫖配置

放在公网上,derp还是有比较明显的特征的,因此我决定加一个鉴权,本来打算基于tailscale的API写一个的,但是它的API最多90天,这样限制就比较大,需要经常更新,我个人的节点很固定,所以我决定写死,有新设备加入手动修改

获得当前网络中所有的节点,写入nodes.json文件

1
tailscale status --json | jq '[recurse | objects | with_entries(select(.key == "PublicKey")) | .[]] | sort'

写了一个最简的鉴权脚本verify_client.go

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
package main

import (
"encoding/json"
"log"
"net/http"
"os"
)

type DERPAdmitClientRequest struct {
NodePublic string
Source string
}

type DERPAdmitClientResponse struct {
Allow bool
}

var allowedNodes []string

func loadNodes(filename string) error {
data, err := os.ReadFile(filename)
if err != nil {
return err
}

var nodeKeys []string
if err := json.Unmarshal(data, &nodeKeys); err != nil {
return err
}

for _, nodeStr := range nodeKeys {
allowedNodes = append(allowedNodes, nodeStr)
}

return nil
}

func handleAdmitRequest(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}

var req DERPAdmitClientRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}

allowed := false
for _, node := range allowedNodes {
if node == req.NodePublic {
allowed = true
break
}
}

resp := DERPAdmitClientResponse{Allow: allowed}
if err := json.NewEncoder(w).Encode(resp); err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}

if allowed {
log.Printf("Node admitted: %x", req.NodePublic)
}
}

func main() {
if err := loadNodes("nodes.json"); err != nil {
log.Fatal("Failed to load nodes:", err)
}

http.HandleFunc("/", handleAdmitRequest)

log.Println("Server listening on :3000")
if err := http.ListenAndServe(":3000", nil); err != nil {
log.Fatal(err)
}
}

构建脚本

Dockerfile

1
2
3
4
5
6
7
8
9
FROM golang:alpine3.20 AS builder
WORKDIR /app
COPY verify_client.go ./
RUN go build -o verify_client verify_client.go

FROM alpine:latest
WORKDIR /app
COPY --from=builder /app/verify_client /app/
CMD ["./verify_client"]

至此,部署完毕,在Tailscale 后台设置一下,我个人是禁用了默认的DERP

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
"derpMap": {
"OmitDefaultRegions": true,
"Regions": {
"900": {
"RegionID": 900,
"RegionCode": "my",
"Nodes": [{
"Name": "1",
"RegionID": 900,
"HostName": "xx.domain.com",
"DERPPort": port,
"STUNPort": stun_port,
}],
},
},
},

另,此种方案还需要有域名,互联网上有人分享了不用域名,仅使用ip配合自签名证书的方案,不过我没有尝试

参考

如何利用 Caddy 搭建 Tailscale 的 Custom DERP Servers

tailscale-derp-client-verifier