SDP 软件定义边界实战:OpenZiti 暗网接入配置

SDP 软件定义边界(Software Defined Perimeter)要解决的是传统 VPN 与端口映射绕不过去的一个死结:只要服务在公网上有入口,攻击者就能对它发起扫描和爆破,区别只是快慢。SDP 的思路是让入口默认不存在——未通过身份认证的设备,连”这个服务在不在”都探测不到;认证通过后,才在设备与网关之间建立一条专属通道。它不是把门锁得更牢,而是让门在没被授权的人眼里根本不存在。

适用场景

  • 后台管理界面、数据库管理端口、运维跳板不能暴露公网,但运维人员需要随时访问。
  • 远程办公场景,不想维护一套全流量回中心的 VPN,也不希望员工设备进入内网后横向可达。
  • 对外只开放 80 与 443,却又要保护一批非 HTTP 服务,端口映射方案做不到。
  • 需要按人、按服务精确授权,而不是”连上 VPN 就拥有整个网段”。

反过来,如果只是三五个固定 IP 访问一台服务器,安全组白名单更简单,上 SDP 属于过度设计。SDP 的价值随人员流动、服务数量、暴露面三者增长。

前置条件

  • 一台有公网 IP 的 Linux 服务器(2 核 4G 起,控制平面与边缘路由器可同机部署)。
  • 一个可解析的域名。控制平面必须用域名签发证书,写 IP 会在客户端校验失败。
  • 业务服务已在本机监听,例如 127.0.0.1:8080。
  • Docker 20.10 以上,客户端支持 Windows、macOS、Linux。

原理说明:三个角色与两条策略

SDP 的架构由三部分组成,理解这三者就理解了全部配置逻辑:

  • 控制平面(Controller):负责身份签发、策略判定、会话授权。它只发凭证,不转发流量。
  • 数据平面(Edge Router):真正的流量入口。它接受已认证客户端建立的隧道,把流量送到后端服务。
  • 身份(Identity):每台设备、每个用户一份证书身份。没有身份,就没有任何入口地址。

业务服务在 SDP 里被抽象成”服务”,由两段配置拼成:host.v1 描述后端真实地址与端口,只有路由器看得到;intercept.v1 描述客户端看到的虚拟域名与端口,只有客户端看得到。客户端访问虚拟域名,流量经隧道到路由器,路由器按 host 配置连真实后端。真实地址从头到尾没有出现在客户端。

授权由两条策略组成,这是最容易配错的地方:Dial 策略规定”谁能访问这个服务”,Bind 策略规定”谁能承载这个服务”。漏掉任何一条,服务都会处于”配置齐全但访问不了”的状态。

对比项 端口映射 / 反代 传统 VPN SDP
未授权者可见性 端口可见,可扫描 网关端口可见 不可见,无响应
授权粒度 按端口 按网段 按身份 + 服务
横向移动风险 低但暴露面大 高,进入即全网可达 低,仅授权服务可达
后端地址暴露 暴露 暴露 不暴露

操作步骤

第一步:拉起控制平面与边缘路由器

先用 quickstart 镜像把三个组件跑起来,跑通再换成正式部署:

# 单机实验环境:控制平面 + 边缘路由器 + 控制台,一条命令拉起
docker run --rm -it --name ziti-quickstart \
  -p 1280:1280 \
  -p 3022:3022 \
  -p 10000:10000 \
  openziti/quickstart

# 另开一个终端,进入容器执行 CLI
docker exec -it ziti-quickstart bash
ziti edge login localhost:1280 -u admin -p admin

正式环境不要把 admin/admin 留在生产,登录后第一件事就是改密码并创建独立的管理账号。控制平面端口 1280 是管理 API,3022 是路由器数据通道,两个都要在域名上签好证书。

第二步:定义服务

创建两个配置,再绑成一个服务。地址与端口务必用双引号包住 JSON,shell 会吃掉单引号里的内容:

# 1) 服务端侧配置:告诉网络"这个服务要连到哪"
ziti edge create config web-host.v1 host.v1 \
  '{"protocol":"tcp","address":"127.0.0.1","port":8080}'

# 2) 客户端侧配置:告诉客户端"访问哪个地址时走隧道"
ziti edge create config web-intercept.v1 intercept.v1 \
  '{"protocols":["tcp"],"addresses":["web.ziti.internal"],"portRanges":[{"low":80,"high":80}]}'

# 3) 把两个配置绑成一个服务
ziti edge create service web-svc --configs web-intercept.v1,web-host.v1

intercept.v1 里的 addresses 是客户端看到的域名,建议用内部专用后缀,例如 web.ziti.internal,避免与公网域名冲突。host.v1 的 127.0.0.1 是相对于路由器所在主机而言的,如果后端在另一台机器,这里要写内网 IP。

第三步:创建身份与授权策略

身份对应设备,策略把身份与服务连起来:

# 客户端身份:员工笔记本
ziti edge create identity device laptop -o /tmp/laptop.jwt

# 授权策略一:laptop 可以"拨号"访问 web-svc
ziti edge create service-policy web-dial Dial \
  --service-roles '@web-svc' \
  --identity-roles '@laptop'

# 授权策略二:只有路由器身份可以"绑定"该服务(即真正连后端)
ziti edge create service-policy web-bind Bind \
  --service-roles '@web-svc' \
  --identity-roles '#routers'

注意 --identity-roles 的两种写法:@名字 是按名称精确匹配,#属性 是按角色属性匹配。#routers 是所有边缘路由器的内置属性,所以 Bind 策略一行就覆盖了全部路由器,新增路由器不需要改策略。

第四步:客户端接入

把 jwt 文件通过安全渠道交给用户,文件一次性有效,用完即失效:

# 客户端安装 tunneler 后执行
sudo ziti edge enroll /tmp/laptop.jwt
sudo ziti tunnel

# 验证隧道是否建立
ziti tunnel status
ip route show table all | grep -i ziti

enroll 完成后,客户端本地会多出一张虚拟网卡和一份身份文件。ziti tunnel 常驻运行,负责拦截发往虚拟域名的连接并封装进隧道。

第五步:验证访问,然后撤掉公网端口

先确认能通,再关端口——顺序反了会把自己锁在外面:

# 直接用服务名访问,不需要知道后端 IP 和端口
curl -sv http://web.ziti.internal/health

# 预期:TCP 连接建立到本地虚拟网卡,由隧道转发到边缘路由器
# * Connected to web.ziti.internal (100.64.0.1) port 80

确认访问正常后,立刻做暴露面收敛。这是 SDP 落地的关键一步,很多人只做了隧道没关端口,等于白做:

# 服务端确认:业务端口只监听回环,公网不可达
ss -lntp | grep 8080
# LISTEN 0 511 127.0.0.1:8080 0.0.0.0:* users:(("java",pid=2041,fd=98))

# 从外部扫描,应当超时而不是被拒绝(无端口暴露)
nmap -Pn -p 8080,22 <公网IP>

# 防火墙只放行控制平面与边缘路由器的入站
sudo iptables -A INPUT -p tcp --dport 1280 -s <管理网段> -j ACCEPT
sudo iptables -A INPUT -p tcp --dport 3022 -j ACCEPT
sudo iptables -A INPUT -p tcp --dport 8080 -j DROP

用 DROP 而不是 REJECT,让扫描器收到超时而不是”连接被拒”。被拒绝会暴露”这里有个服务只是被挡了”,超时则让目标看起来不存在,这正是 SDP 想要的不可见性。

配置验证

配置完成后按下面的顺序核对,任何一步缺失都会导致访问失败:

# 服务与策略是否成对存在(有服务无策略 = 谁都访问不了)
ziti edge list services
ziti edge list service-policies

# 查看某身份的授权结果,确认 dial 权限生效
ziti edge list identities 'name="laptop"'
ziti edge list edge-routers

# 会话是否建立(客户端访问时这里应出现 session)
ziti edge list sessions

如果客户端报 service not found,问题在 intercept 配置或服务未创建;如果报 permission denied,问题在 Dial 策略;如果连接建立但读不到数据,问题在 Bind 策略或 host 配置里的地址端口。三类报错对应三个不同的配置层,按这个映射排查,比逐项翻配置快得多。

常见问题

Q1:SDP 和零信任网络访问(ZTNA)是一回事吗?

ZTNA 是目标,SDP 是实现路径之一。SDP 的原始定义强调”先认证后连接”与”网络层不可见”,用单包授权(SPA)或隧道实现;ZTNA 的范围更宽,身份感知反向代理也算。两者的区别在实现位置:反向代理式的方案在应用层做鉴权,后端仍需一个可达的入口;SDP 在网络层就把未授权流量挡在外面,后端连监听都不在公网。选型时看后端是否允许保留一个公网入口,不允许就只能走 SDP。

Q2:客户端必须装软件,能不能用浏览器直接访问?

可以,但那是另一条路径。OpenZiti 提供 browZer 方案,把隧道能力放进浏览器,代价是需要额外的证书配置与前端集成,兼容性不如原生客户端。经验上,运维人员装客户端没问题;面向大量外部用户的场景,用反向代理加身份认证更省事,不要为了”统一架构”强行上浏览器隧道。

Q3:路由器单点故障怎么处理?

再部署一个边缘路由器,用同一个控制平面管理。客户端会根据延迟自动选择可用路由器,Bind 策略里的 #routers 已经覆盖新节点,不需要改策略。注意控制平面本身也要考虑冗余,它是签发身份的根,挂掉后新设备无法接入,但已建立的隧道路由仍然可用。

Q4:迁移期间新旧方案并行,怎么避免混乱?

按服务维度迁移,不要按用户。一个服务迁完并验证通过后关闭它的公网端口,再迁下一个。并行期间在防火墙上对旧入口加访问日志,确认没有残留调用后再删规则。整批一起切最容易出现”某个老客户端还在走旧端口”的事故。

总结

SDP 的落地动作可以浓缩成一句话:给服务建一个只在认证后存在的入口,然后关掉原有的公网端口。前一半靠 intercept 配置与身份策略,后一半靠防火墙规则,缺任何一半都不算完成。技术上最容易错的是 Bind 与 Dial 两条策略的配对,管理上最容易错的是迁完忘了关端口。对中小企业来说,先从一两个后台管理服务试点,把身份发放、策略维护、故障排查三个流程跑顺,再推广到全部内网服务,比一次性替换 VPN 稳妥得多。