论远程零信任访问的搭建(1)Headscale的搭建

2213 字
11 分钟
论远程零信任访问的搭建(1)Headscale的搭建

前言#

在迁移AquaDX到K8s集群中的时候,我需要给别人远程访问的权限

我的服务器是不公开的 我并不打算公开出去给所有人用

之前用的ZeroTier因为几个原因我决定废弃了:

  • 他把路由选项从控制面板中删除了,改为要付费的
  • 他要修改宿主机的tun配置来实现二层上的网络代理,这在K8s中是不允许的
  • 打洞策略太迷 即使能UPnP直连或者IPv6直连有时候他还是中转

第一点导致我没法用来代理整个网段了,以前我把他装在OpenWrt上,这样我可以连接上网络之后访问我整个局域网,现在没了

第二点则是他的硬伤,或许我们有方法让他不修改tun,但显然没必要

我们可以选择性能更高的三层网络方案,基于WireGuard的一大堆都可以

我看到了Github上的这个:https://github.com/HarvsG/WireGuardMeshes/blob/main/readme.md

Code_qMmZJDTt8C.png
Code_qMmZJDTt8C.png

可以看到最成熟的应该是NetBird和Tailscale

我在之前是使用过Tailscale的,也确实很成熟

NetBird也不差

考虑到我用过Tailscale,我决定还是用他

但是Tailscale打洞中转延迟非常高,因为国内没有服务器

那么有没有最夯的方式呢

自建Headscale#

我其实想过自建Netbird,但我一打开Netbird那网站和Tailscale也差不多嘛

上面那个图的HeadScale其实就是Tailscale控制平面的开源实现

TailScale客户端是开源的,控制端则闭源 和Zerotier一样

事实上的话我们可以自己搭建中继节点,然后用Tailscale官方的功能就完全足够了

但是都要自己搭了我干嘛不把控制平面也自己弄了呢()顺便解锁一些收费才能用的功能

也能同时解决打不了洞难中转的问题

其实的话不难,Headscale麻烦的是那个config

---
apiVersion: apps/v1
kind: Deployment
metadata:
annotations: {}
labels:
app: headscale
k8s.kuboard.cn/name: headscale
name: headscale
namespace: headscale
spec:
replicas: 1
selector:
matchLabels:
app: headscale
labels:
app: headscale
spec:
containers:
- command:
- headscale
- serve
image: 'docker.io/headscale/headscale:stable'
imagePullPolicy: IfNotPresent
name: headscale
ports:
- containerPort: 8080
name: web
protocol: TCP
- containerPort: 9090
name: metrics
protocol: TCP
- containerPort: 50443
name: grpc
protocol: TCP
volumeMounts:
- mountPath: /var/lib/headscale
name: headscale-data
- mountPath: /var/run/headscale
name: headscale-data
- mountPath: /etc/headscale
name: config
readOnly: true
volumes:
- name: headscale-data
persistentVolumeClaim:
claimName: headscale-data
- configMap:
defaultMode: 420
items:
- key: server-config.yaml
path: config.yml
name: server-config.yaml
name: config
---
apiVersion: v1
kind: Service
metadata:
annotations:
metallb.io/ip-allocated-from-pool: default-address-pool
labels:
app: headscale
k8s.kuboard.cn/name: headscale
name: headscale
namespace: headscale
spec:
allocateLoadBalancerNodePorts: true
clusterIP: 10.110.187.183
clusterIPs:
- 10.110.187.183
externalTrafficPolicy: Cluster
internalTrafficPolicy: Cluster
ipFamilies:
- IPv4
ipFamilyPolicy: SingleStack
ports:
- name: web
nodePort: 30667
port: 8080
protocol: TCP
targetPort: 8080
- name: metrics
nodePort: 32383
port: 9090
protocol: TCP
targetPort: 9090
- name: grpc
nodePort: 32594
port: 50443
protocol: TCP
targetPort: 50443
selector:
app: headscale
sessionAffinity: None
type: LoadBalancer
status:
loadBalancer:
ingress:
- ip: 10.0.100.11
ipMode: VIP

然后的话是config

server_url: http://127.0.0.1:8080
listen_addr: 0.0.0.0:8080
metrics_listen_addr: 0.0.0.0:9090
grpc_listen_addr: 0.0.0.0:50443
grpc_allow_insecure: true
noise:
private_key_path: /var/lib/headscale/noise_private.key
prefixes:
v4: 100.64.0.0/10
v6: fd7a:115c:a1e0::/48
allocation: sequential
derp:
server:
enabled: false
urls:
- https://controlplane.tailscale.com/derpmap/default
auto_update_enabled: true
update_frequency: 3h
disable_check_updates: false
ephemeral_node_inactivity_timeout: 30m
database:
type: sqlite
sqlite:
path: /var/lib/headscale/db.sqlite
write_ahead_log: true
wal_autocheckpoint: 1000
acme_url: https://acme-v02.api.letsencrypt.org/directory
tls_letsencrypt_cache_dir: /var/lib/headscale/cache
tls_cert_path: ""
tls_key_path: ""
log:
level: info
format: text
policy:
mode: file
path: ""
dns:
magic_dns: true
base_domain: headscale.lan
override_local_dns: true
nameservers:
global:
- 1.1.1.1
- 8.8.8.8
split: {}
search_domains: []
extra_records: []
unix_socket: /var/run/headscale/headscale.sock
unix_socket_permission: "0770"
logtail:
enabled: false
randomize_client_port: false

不出意外就没问题了,但是Headscale没有GUI,搭起来之后我们得用CLI访问

这行吗 这肯定不行啊

我们可以参考Headscale的官方界面选一个GUI

https://headscale.net/stable/ref/integration/web-ui/

我选了一下有四个

https://github.com/simcu/headscale-ui

https://github.com/tale/headplane

https://github.com/GoodiesHQ/headscale-admin

https://github.com/rickli-cloud/headscale-console

根据自己对UI的喜好选一个就好

最后我根据Star数和更新频率决定选择headplane

他的UI也是仿造的Tailscale 把人饭碗抢光光了

也好,熟悉Tailscale可以直接上手

去看一下这玩意还支持反代,SSO,SSH key 完美

看了一下为了Headplane能管理,最好的方式是把他们跑在一起

我一开始想的是给他们挂在同一个Deployment下 使用两个工作容器

其实没必要,分开也是完全可以的

因为我把headscale和headplane看混好几次

这他妈也太像了吧

为了便于区分,之后Headscale简称Scale,Headplane简称Plane

chrome_HxIF1PpUZy.png
chrome_HxIF1PpUZy.png

不管怎么说,我们先把他分开部署

请把Plane和Scale放在同一个Deployment中,详情看后面就知道了😄

---
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
k8s.kuboard.cn/name: plane
name: plane
namespace: headscale
spec:
progressDeadlineSeconds: 600
replicas: 1
revisionHistoryLimit: 2
selector:
matchLabels:
k8s.kuboard.cn/name: plane
template:
metadata:
labels:
k8s.kuboard.cn/name: plane
spec:
containers:
- image: 'ghcr.io/tale/headplane:latest'
imagePullPolicy: IfNotPresent
name: headplane
ports:
- containerPort: 3000
name: web
protocol: TCP
volumeMounts:
- mountPath: /etc/headplane
name: config
- mountPath: /var/lib/headplane
name: data
- mountPath: /etc/headscale
name: scale-config
readOnly: true
volumes:
- configMap:
items:
- key: config.yml
path: config.yaml
name: plane-config
name: config
- name: data
persistentVolumeClaim:
claimName: headplane-data
- configMap:
items:
- key: server-config.yaml
path: config.yaml
name: server-config.yaml
name: scale-config
---
apiVersion: v1
kind: Service
metadata:
annotations: {}
labels:
k8s.kuboard.cn/name: plane
name: plane
namespace: headscale
ports:
- name: web
port: 3000
protocol: TCP
targetPort: 3000
selector:
k8s.kuboard.cn/name: plane
sessionAffinity: None
type: ClusterIP
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
annotations: {}
labels:
k8s.kuboard.cn/name: plane
name: plane
namespace: headscale
spec:
ingressClassName: nginx-ingress
rules:
- host: headplane.k8s.lan
http:
paths:
- backend:
service:
name: plane
port:
number: 3000
path: /
pathType: Prefix

然后还有Config

server:
host: "0.0.0.0"
port: 3000
cookie_secret: "$(openssl rand -hex 16)一个在这里"
cookie_secure: false # 不改的话不是https不会发送Cookie
data_path: "/var/lib/headplane"
headscale:
url: "http://headscale.headscale.svc.cluster.local"
config_path: "/etc/headscale/config.yaml"
config_strict: true
integration:
agent:
enabled: false
pre_authkey: "<your-preauth-key>"
docker:
enabled: false
container_label: "me.tale.headplane.target=headscale"
socket: "unix:///var/run/docker.sock"
kubernetes:
enabled: true
validate_manifest: true
pod_name: "headscale"
proc:
enabled: false

我这里把OIDC段删了,我们先跑起来再去配置

然后就报错了

chrome_o0shplXi5G.png
chrome_o0shplXi5G.png

傻逼了 因为我们设置了kubernetes integration

简单来说就是可以让Plane自动发现集群中的Scale服务,自动通信

我们现在需要设置一下Service Account,默认的default是没有任何权限的

我们需要让plane可以查看访问同命名空间下的pod

---
apiVersion: v1
kind: ServiceAccount
metadata:
name: headplane
namespace: headscale
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: headplane-role
namespace: headscale
rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: headplane-role
namespace: headscale
rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list", "watch"]

然后修改一下Plane让他使用这个ServiceAccount

chrome_yEXT2ASdGI.png
chrome_yEXT2ASdGI.png

重启一下

chrome_aWvroyqhBG.png
chrome_aWvroyqhBG.png

尼玛。

chrome_7tRqUk9g66.png
chrome_7tRqUk9g66.png

我想到Pod名字是随机的,我们在Config里填的

kubernetes:
enabled: true
validate_manifest: true
pod_name: "headscale"

他其实还有一行注释在上面:

kubernetes:
enabled: true
validate_manifest: true
# This should be the name of the Pod running Headscale and Headplane.
# If this isn't static you should be using the Kubernetes Downward API
# to set this value (refer to docs/Integrated-Mode.md for more info).
pod_name: "headscale"

我去看看他的文档先:

chrome_cVrkDNWzro.png
chrome_cVrkDNWzro.png

chrome_4tnEd6YmDo.png
chrome_4tnEd6YmDo.png

chrome_aOqefgYEOX.png
chrome_aOqefgYEOX.png

?你这个文档在哪呢

反正说白了就是,Pod名字是会变动的,我们可以用Downward API去解决这个问题

等一下 席巴了 通过这个注入podname需要同一个Deployment

chrome_bdyAWlASSu.png
chrome_bdyAWlASSu.png

我真是草了

其实也不用说非得注入,我们可以把Scale改成StatefulSet,这样他的名字就是固定的了

其实的话integration是可以false的,我们在上面已经通过集群内域名实现Plane对Scale的访问了

但我已经折腾到这了 给他一起配上吧

把Plane也放到同一个Deployment里作为另一个工作容器

等一下啊 configmap是只读的

那怎么让Plane修改config

chrome_7tRqUk9g66.png
chrome_7tRqUk9g66.png

我真红了 我写到这已经边折腾边写3小时了 直接用Tailscale的话Sidecar都跑起来收工了

卧槽我们跑起来还要跑Sidecar呢

chrome_f2YiWJ9496.png
chrome_f2YiWJ9496.png

没话说了 折腾到这我已经有点沉默了 我们先不管integration 先看看能不能用

chrome_VmyRTHeNjl.png
chrome_VmyRTHeNjl.png

chrome_0ezfV0GyRt.png
chrome_0ezfV0GyRt.png

欸等一下

给自己折腾傻了,Plane的默认界面是/admin

chrome_W6NFyj0mmt.png
chrome_W6NFyj0mmt.png

白浪费一个小时去来回调,先用Service访问不香吗(

那我们进headscale容器生成一个key先

这headscale容易默认啥shell都没有

我们还得去master节点exec一下

MobaXterm_HhxL4xJ142.png
MobaXterm_HhxL4xJ142.png

忘记指定命名空间了 真玩傻了

Terminal window
kubectl exec -n headscale -it headscale-59bd5c7d65-8j5k6 -- headscale apikeys create

然后就可以进到管理界面了

那就剩一个小问题

chrome_D5PlYdJGjl.png
chrome_D5PlYdJGjl.png

我们得把Configmap拷贝到PVC中并允许多节点同时读写

我选择用一个init pod自动拷贝,这样就能解决问题了

chrome_tK9Qo4sPEJ.png
chrome_tK9Qo4sPEJ.png

chrome_hO3RyeKrJP.png
chrome_hO3RyeKrJP.png

然后就在这时候

MobaXterm_Uma9Js4Akw.png
MobaXterm_Uma9Js4Akw.png

Lens_H0cPn9el6f.png
Lens_H0cPn9el6f.png

卧槽Worker炸了

真是坎坎又坷坷啊

我一看 普罗米修斯在我的Worker里给人类偷取火种去了

MobaXterm_yXuv6WzAlj.png
MobaXterm_yXuv6WzAlj.png

西巴了搞个wg组网搞一晚上

我先把Grafana监控套件全删了

这个是题外话 修好之后就可以正常使用headplane了

chrome_h8OGPj5A47.png
chrome_h8OGPj5A47.png

写了500多行了 终于完事了

那我们就顺便配置下OIDC吧

只需要在Plane的下面加上这些就好了

Scale同理,这样的话登录的时候就不用去手动输入Machine key,过OIDC就好了

oidc:
issuer: "https://example.com/application/o/headplane-oidc/"
client_id: "secret"
client_secret: "secret"
scope: "openid email profile"
disable_api_key_login: true
token_endpoint_auth_method: "client_secret_post"
headscale_api_key: "api key"
redirect_uri: "https://example.com/admin/oidc/callback"

Authentik上的话,别的都和正常创建OIDC一样,最关键的是要选择一个签名证书,并且是RS256加密的

chrome_4vfiXv3hhx.png
chrome_4vfiXv3hhx.png

这样的话就可以实现加入网络和管理都用同一套帐号了

完事 接下来是Sidecar 太长了我决定放到另一篇

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

论远程零信任访问的搭建(1)Headscale的搭建
https://cainongw.github.io/posts/remote-access1/
作者
Cainong
发布于
2025-11-01
许可协议
CC BY-NC-SA 4.0
相关文章 智能推荐
1
论远程零信任访问的搭建(2)Sidecar容器的使用
HomeLab 前言 我们终于搭建好了Headscale控制平面和UI,并解决了统一登陆,现在零信任网络已经完全可以使用了 我们需要完成最后一步:把服务添加进来 在这里就是我的AquaDX Sidecar容器
2
开源WAF 雷池在K8s上的搭建
HomeLab 前言 换了条宽带,现在我拥有动态的公网IPV4地址了 我打算暴露一些服务出去以便我使用 例如Openlist RDP Gitlab 但是 我并不打算公开 我只打算自己使用 前面我们搭建了Authentik作为SSO单点登录,刚好雷池WAF支持通过单点登录鉴权来允许访问 这样我就可以实现把ESXi挂到公网 但是不登录都过不了防火墙
3
记一次filebrowser的搭建
Kubernetes 起因 我以为这个就是写个Deployment然后挂载NFS就得了 本来都不打算写blog的 结果后面发现折腾了我几个小时才好 还是有一点坑的 所以就写一下
4
记一次Kubernetes完整集群的搭建
HomeLab 写在前面 太好了孩子们 这次基本全都是命令行操作 我不用截图了 以下几乎全是命令 几乎一张图片没有 如果你想做为参考的话请仔细阅读每一行 前言 昨天我在折腾Authentik认证服务的时候 一直在思考用什么反代服务 常见的可以用Nginx Proxy Manager,Caddy,Traefik 这里面我觉得比较好用的是Traefik,但是即使是Traefik
5
记一次基于OpenWRT+Nginx反代的局域网服务小改造
HomeLab 前言 随着服务器上面跑的东西越来越多,docker跑的服务也越来越多了 我常访问所以我可以记住每个虚拟机的ip或者docker的端口 但是每次在浏览器输入都得输入192然后手动补全,有时候甚至得冒号加端口号才可以 太特么麻烦了
随机文章 随机推荐
Profile Image of the Author
Cainong
Caiw there 👋
公告
欢迎来到我的博客!这是一则示例公告。
音乐
封面

音乐

暂未播放

0:00 0:00
暂无歌词
分类
站点统计
文章
38
分类
16
标签
48
总字数
57,650
运行时长
0
最后活动
0 天前

文章目录