跳过正文
零信任网络改造:从公网暴露到 Headscale VPN

零信任网络改造:从公网暴露到 Headscale VPN

·756 字·4 分钟·
目录
SRE 实战手册 - 这篇文章属于一个选集。
§ : 本文

为什么要做这件事
#

某天做常规安全审查,用 shodan 和 nmap 扫了一遍我们的公网暴露资产,结果让我有点坐不住:

  • ArgoCD UI 暴露在公网(用 NodePort,临时的,结果忘了)
  • Grafana 有公网入口,只有弱口令保护
  • 几个服务的 metrics 端口(9090)直接对公网
  • 一台用于应急的跳板机 SSH 开放在公网,端口 22

当时的安全策略是"加 IP 白名单",但白名单维护越来越混乱,有些条目的来源已经无从追溯。

更大的问题是:这套系统对"内部"和"外部"的边界判断基于 IP 地址,而 IP 地址在云环境下很难成为可靠的信任依据——研发在家办公怎么办?出差的工程师怎么办?开发机被入侵的风险呢?

零信任的核心思路是:不信任任何网络位置,每个连接都要验证身份。这次改造的目标就是把所有运维系统从公网撤回来,统一走 VPN,以身份而非 IP 地址作为信任依据。


现状梳理:公网暴露资产扫描
#

改造之前,先摸清楚有哪些东西暴露在外面。

# 用 nmap 扫自己的公网 IP 段
nmap -sV -p 22,80,443,2376,2379,6443,8080,8443,9090,9093 \
  --open \
  x.x.x.0/24

# 检查 AWS 安全组,找出 0.0.0.0/0 的入站规则
aws ec2 describe-security-groups \
  --filters Name=ip-permission.cidr,Values=0.0.0.0/0 \
  --query 'SecurityGroups[*].{ID:GroupId,Name:GroupName,Rules:IpPermissions}' \
  --output table

# 检查 K8s 中 type=LoadBalancer 或 NodePort 的 Service
kubectl get svc --all-namespaces | grep -E 'LoadBalancer|NodePort'

扫描后整理成清单:

服务暴露方式端口风险级别处理方案
ArgoCDNodePort30080撤回内网,走 VPN
GrafanaLoadBalancer443撤回内网,走 VPN
Metrics 端口安全组 0.0.0.0/09090收紧安全组
跳板机 SSH安全组 0.0.0.0/022改为 VPN 接入,关闭公网

方案选型:Headscale vs Tailscale vs WireGuard
#

市面上有几种方案可选:

纯 WireGuard
#

WireGuard 是底层 VPN 协议,性能极好,配置相对简单,但:

  • 没有 peer 自动发现,每台机器都要手动配置对端 public key 和 endpoint
  • 没有 NAT 穿透支持,家庭网络(CG-NAT)下不稳定
  • 没有用户管理界面,设备多了维护成本高

适合:节点数量少(< 10 台),不需要频繁动态加入新设备。

Tailscale(SaaS)
#

Tailscale 在 WireGuard 基础上构建了完整的 mesh VPN:

  • 自动 NAT 穿透(基于 DERP relay)
  • 用户/设备管理
  • ACL 访问控制
  • 免费版支持 3 个用户

问题是:控制面在 Tailscale 的服务器上,企业数据流量的 key 管理对第三方有依赖。对安全要求高或者有数据合规要求的团队,这一点是接受不了的。

Headscale(自托管 Tailscale 控制面)
#

Headscale 是 Tailscale 控制面的开源实现,数据面仍然走 WireGuard,但控制面完全自托管:

  • 自己掌握所有节点 key
  • 兼容 Tailscale 客户端(不需要额外客户端)
  • 支持 MagicDNS(节点之间用主机名互访)
  • 开源,社区活跃

缺点:需要自己维护服务,功能比 SaaS Tailscale 少(如无 SSO 集成,需要额外配置)。

我们的选择:Headscale,控制面自托管,符合数据安全要求,客户端兼容性好。


Headscale 部署
#

服务端部署
#

选一台有公网 IP 的小机器(跳板机或专用 VPN 节点)部署 Headscale:

# 下载最新版本(以 0.23.0 为例)
wget https://github.com/juanfont/headscale/releases/download/v0.23.0/headscale_0.23.0_linux_amd64
chmod +x headscale_0.23.0_linux_amd64
mv headscale_0.23.0_linux_amd64 /usr/local/bin/headscale

配置文件 /etc/headscale/config.yaml

server_url: https://headscale.example.com
listen_addr: 0.0.0.0:8080
metrics_listen_addr: 0.0.0.0:9090

# IP 地址段分配给 VPN 内部设备
ip_prefixes:
  - fd7a:115c:a1e0::/48
  - 100.64.0.0/10

# DNS 配置(MagicDNS)
dns_config:
  nameservers:
    - 1.1.1.1
  domains: []
  magic_dns: true
  base_domain: vpn.internal

# DERP(relay 服务器,用于 NAT 穿透)
derp:
  server:
    enabled: false   # 自己的 DERP 服务可以后续开启
  urls:
    - https://controlplane.tailscale.com/derpmap/default   # 先用 Tailscale 的公共 DERP

# 数据库(使用 SQLite,节点少时足够)
database:
  type: sqlite
  sqlite:
    path: /var/lib/headscale/db.sqlite

# TLS 配置(使用 nginx 反代 + Let's Encrypt)
tls_cert_path: ""
tls_key_path: ""

systemd 服务:

[Unit]
Description=Headscale VPN Control Server
After=network.target

[Service]
User=headscale
Group=headscale
ExecStart=/usr/local/bin/headscale serve
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target
systemctl enable headscale
systemctl start headscale

Nginx 反代(443 → Headscale 8080):

server {
    listen 443 ssl http2;
    server_name headscale.example.com;

    ssl_certificate /etc/letsencrypt/live/headscale.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/headscale.example.com/privkey.pem;

    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_set_header X-Real-IP $remote_addr;
    }
}

用户和设备管理
#

# 创建用户(相当于 Tailscale 的 namespace)
headscale users create devteam
headscale users create opsengineers

# 生成设备注册 key(工程师用来接入 VPN)
headscale preauthkeys create --user devteam --expiration 24h --reusable

# 查看已接入设备
headscale nodes list

# 输出示例
ID  Hostname        User        IP Addresses                 Last Seen
1   dev-mbp         devteam     100.64.0.1, fd7a:...         2025-12-09 14:30
2   ops-server-1    opsengineers 100.64.0.2, fd7a:...        2025-12-09 14:28

客户端接入(工程师侧)
#

# macOS / Linux 安装 Tailscale 客户端
brew install tailscale   # macOS

# 连接到自托管的 Headscale
tailscale up --login-server https://headscale.example.com

# 查看连接状态
tailscale status

和 Kubernetes 集成
#

kubectl over VPN
#

将 API Server 的公网入口收回,只保留 VPN 内网访问:

# 修改 EKS 集群安全组:只允许 VPN 内网段访问 6443
aws ec2 authorize-security-group-ingress \
  --group-id sg-xxxxxxxxxx \
  --protocol tcp \
  --port 6443 \
  --cidr 100.64.0.0/10   # Headscale 分配的 VPN IP 段

# 撤销公网访问
aws ec2 revoke-security-group-ingress \
  --group-id sg-xxxxxxxxxx \
  --protocol tcp \
  --port 6443 \
  --cidr 0.0.0.0/0

更新 kubeconfig 使用内网地址:

# ~/.kube/config
clusters:
- cluster:
    server: https://api.prod-cluster.vpn.internal:6443   # VPN 内网域名
  name: prod-cluster

ArgoCD 撤回内网
#

把 ArgoCD 的 Service 类型从 LoadBalancer 改为 ClusterIP,用 VPN 内网 + kubectl port-forward 或内网 Ingress 访问:

# argocd-server service
apiVersion: v1
kind: Service
metadata:
  name: argocd-server
  namespace: argocd
spec:
  type: ClusterIP   # 从 LoadBalancer 改为 ClusterIP
  ports:
  - port: 443
    targetPort: 8080
# 工程师在 VPN 内通过 port-forward 访问
kubectl port-forward svc/argocd-server -n argocd 8080:443
# 然后访问 https://localhost:8080

接入流程设计
#

开发工程师接入流程
#

  1. 运维创建预授权 key(设置 24h 有效期)
  2. 工程师安装 Tailscale 客户端,使用 key 加入 VPN
  3. 运维在 Headscale 确认设备注册,分配到对应 user group
  4. 工程师可以访问 QA/PRE 环境,PROD 需要额外申请

ACL 访问控制
#

Headscale 支持 Tailscale 的 ACL 格式,按 user group 控制访问权限:

{
  "groups": {
    "group:devs": ["devteam"],
    "group:ops": ["opsengineers"]
  },
  "acls": [
    // 开发组:只能访问 QA 和 PRE 的 K8s API
    {
      "action": "accept",
      "src": ["group:devs"],
      "dst": ["100.64.0.10:6443", "100.64.0.11:6443"]
    },
    // 运维组:全部访问权限
    {
      "action": "accept",
      "src": ["group:ops"],
      "dst": ["*:*"]
    }
  ]
}

收敛过程中的挑战
#

挑战 1:老系统的硬编码公网地址

有些监控 agent 和日志收集器硬编码了公网 IP。迁移时需要逐一修改配置,比预想的工作量大。

解决:建一个映射表,把公网地址和 VPN 内网地址对应起来,用 DNS CNAME 过渡,给老系统一个缓冲期。

挑战 2:CI/CD 系统的访问权限

GitHub Actions runner 在公网,撤销 API Server 公网入口后,CI 流水线无法部署到 K8s。

解决方案 1:在 K8s 集群内部署 self-hosted runner,从集群内部访问 API Server。

解决方案 2:让 runner 通过 Headscale API 动态注册为节点,完成部署后注销。

我们选了方案 1,self-hosted runner 顺便解决了 CI 机器规格不够的问题。

挑战 3:DERP relay 稳定性

早期用 Tailscale 的公共 DERP 服务器,国内访问延迟高。后来在阿里云部署了自己的 DERP 节点,延迟降到了 30ms 以内。

# headscale config.yaml:配置自建 DERP
derp:
  paths:
    - /etc/headscale/derp.yaml

# /etc/headscale/derp.yaml
regions:
  900:
    regionid: 900
    regioncode: cn-hangzhou
    regionname: Aliyun Hangzhou
    nodes:
      - name: 900a
        regionid: 900
        hostname: derp-cn.example.com
        ipv4: x.x.x.x
        derpport: 443
        stunport: 3478

改造后的变化
#

改造完成两个月,几个明显的变化:

  1. 安全告警减少了:Cloudtrail 和安全组里来自陌生 IP 的扫描行为基本消失
  2. 管理复杂度降低:不再需要维护 IP 白名单,新同事接入只需要分发一个 pre-auth key
  3. 跳板机退役:那台专门用来跳板的 EC2 终于关掉了,每月省了一点机器费用
  4. 审计更清晰:Headscale 的日志记录了每个设备的连接记录,谁在什么时候访问了什么,有迹可查

零信任不是一次性改造,而是一个持续收紧的过程。后续还计划做设备合规检查(只有装了 EDR 的设备才能加入 VPN)和操作审计(所有 kubectl 操作记录到日志系统)。


回头看,最大的感受是:安全改造的时机永远是"现在",等到出了事再做往往代价更大。这件事拖了半年才开始做,幸好没有在这半年里出什么问题。

Wenzhuo Huang
作者
Wenzhuo Huang
搞运维的工程师,写代码的运维人。专注 Kubernetes、AWS、GitOps 与基础设施可靠性。这个博客既是我的技术笔记本,也是我踩过的坑的受害者档案。
SRE 实战手册 - 这篇文章属于一个选集。
§ : 本文

相关文章

Kubernetes NetworkPolicy 网络隔离实战

·2505 字·12 分钟
系统讲解 Kubernetes NetworkPolicy 的工作机制与生产实战配置,覆盖 deny-all 基础模板、常见隔离场景、Cilium 扩展、多租户设计、测试验证方法及常见陷阱。

如何设计一个好的告警体系

·570 字·3 分钟
从真实的告警噪音泛滥经历出发,分享如何用 SLI/SLO 重新设计告警体系,包括告警分级、规则设计原则、路由策略和复盘机制。

用 WireGuard 构建多云 mesh VPN:从点对点到全网互联

·1592 字·8 分钟
一份从实战出发的 WireGuard mesh VPN 笔记:讲清楚为什么不用 IPSec/OpenVPN、手写配置 vs Netmaker vs Tailscale 的选型对比、AWS 与阿里云跨云 mesh 的真实部署方案、MTU 与 NAT 穿透的踩坑,以及自动化密钥分发与监控方案。