Consul 服务发现加固实战:ACL 与 mTLS 配置

Consul 服务发现加固要解决的是同一个根问题:Consul 出厂状态是”完全信任网络的注册中心”。8500 端口无认证,8301 端口的 gossip 明文传输,任何能连上端口的进程都能读走全部服务拓扑、健康检查结果和 KV 配置。本文按三条独立信任链给出可落地的加固配置——gossip 加密、双向 TLS、ACL 默认拒绝,并附带验证与轮换方法。

适用场景

  • 微服务用 Consul 做服务注册与配置中心,集群跨机房、跨云或跨 VPC
  • Consul UI 与 HTTP API 暴露在内网,等于把整张服务拓扑图交给了内网任何一个被入侵的跳板机
  • KV 中存放数据库连接串、第三方密钥等敏感配置,需要按服务维度隔离读权限
  • 等保或内控要求控制面组件必须启用身份认证与传输加密

前置条件

  • Consul 1.16 以上,三节点 server 集群(示例为 dc1)
  • 已规划服务端证书主机名,格式为 server.dc1.consul
  • consul CLI 已加入 PATH,各节点时间同步(chrony 或 systemd-timesyncd)
  • 节点间 8300/8301/8302 端口互通,客户端到服务端 8500/8501 可达
  • 具备重启 agent 的窗口,ACL 引导期间集群会短暂只读

原理说明

Consul 有四条对外通道,默认全部明文、且除 ACL 外默认不启用任何鉴别:server RPC 8300(server 之间复制日志)、serf LAN 8301(同数据中心 gossip)、serf WAN 8302(跨数据中心 gossip)、HTTP API 8500 与 DNS 8600。端口暴露面比大多数人以为的宽得多。

三条互不替代的信任链

  • gossip 加密:对称密钥(AES-GCM),保护 8301/8302 的成员发现流量,防止攻击者伪造节点加入集群
  • TLS 证书:非对称,保护 8300 与 8500,verify_incoming 校验入向、verify_outgoing 校验出向、verify_server_hostname 防止普通客户端证书冒充 server
  • ACL:Bearer token,控制”谁能读哪些资源”,default_policy=deny 是唯一正确的起点

三者组合才完整:只开 TLS 不开 ACL,任何拿到证书的机器都能读全部 KV;只开 ACL 不开 TLS,token 在链路上明文可嗅,等于把钥匙写在信封外面;只开 gossip 加密而 RPC 明文,攻击者仍可直接向 8300 提交伪造的注册信息。

KV 授权按前缀生效,别把凭据堆在一个前缀里

ACL 对 KV 的授权粒度是前缀,key_prefix "config/" { policy = "read" } 会让持有该 token 的任何服务读走这个前缀下的全部内容。把数据库口令、第三方密钥和普通业务开关放在同一个前缀,等于给所有只读服务发了一份完整凭据清单。合理做法是按”密钥 / 业务配置 / 灰度开关”拆成三个前缀,密钥前缀只授予确需读取的少数 token。

操作步骤

步骤一:生成 gossip 加密密钥

consul keygen
# 输出形如:cq4mF0kZ...(32 字节 base64,集群内所有节点必须完全一致)

# 分发给各节点,注意权限
install -d -m 750 /etc/consul.d/secrets
consul keygen > /etc/consul.d/secrets/gossip.key
chmod 600 /etc/consul.d/secrets/gossip.key

步骤二:签发 CA 与服务端证书

使用 Consul 内置的 TLS 工具链即可,避免手工 openssl 配置出错。-server 生成带 server 用途的证书,-dc 决定主机名后缀:

mkdir -p /etc/consul.d/tls && cd /etc/consul.d/tls

consul tls ca create -domain=consul
# 产出 consul-agent-ca.pem / consul-agent-ca-key.pem

consul tls cert create -server -dc=dc1 -domain=consul
# 产出 dc1-server-consul-0.pem / -key.pem / -chain.pem

cp dc1-server-consul-0.pem server.crt
cp dc1-server-consul-0-key.pem server.key
cp dc1-server-consul-0-chain.pem ca.crt
chmod 600 server.key

步骤三:写入服务端 agent 配置

核心是三个 verify 开关必须全部为 true,并把默认 HTTP 端口挪到 http 之外的 https,防止有人继续走明文端口:

{
  "datacenter": "dc1",
  "node_name": "consul-1",
  "bind_addr": "10.0.0.11",
  "client_addr": "127.0.0.1",

  "encrypt": "<上一步 keygen 的 32 字节 base64>",
  "encrypt_verify_incoming": true,
  "encrypt_verify_outgoing": true,

  "verify_incoming": true,
  "verify_outgoing": true,
  "verify_server_hostname": true,
  "ca_file": "/etc/consul.d/tls/ca.crt",
  "cert_file": "/etc/consul.d/tls/server.crt",
  "key_file": "/etc/consul.d/tls/server.key",

  "ports": {
    "https": 8501,
    "http": -1,
    "grpc": -1,
    "grpc_tls": 8503,
    "dns": 8600
  },

  "acl": {
    "enabled": true,
    "default_policy": "deny",
    "enable_token_persistence": true,
    "down_policy": "extend-cache"
  },

  "ui_config": { "enabled": false },
  "limits": { "http_max_conns_per_client": 200 }
}

http: -1 表示彻底关闭明文 HTTP 监听,这一步经常被漏掉——很多人加了 TLS 但仍然保留 8500,攻击者绕一圈又回到明文入口。

步骤四:ACL 引导与最小权限策略

# 只在第一个 server 上执行一次,产出 management token
consul acl bootstrap

# 验证策略:只读服务目录与节点信息
cat >/etc/consul.d/app-read.hcl <<'EOF'
service_prefix "" {
  policy = "read"
}
node_prefix "" {
  policy = "read"
}
EOF

consul acl policy create -name app-read -rules @/etc/consul.d/app-read.hcl

# 为每个服务实例签发独立 token,便于单独吊销
consul acl token create -description "web-01" -policy-name app-read

关键原则是 一实例一 token。共用一个全局 token 的后果是:任何一台机器的配置泄露,等于整个集群的读权限泄露,且无法定位是谁在滥用。写权限(注册服务、写 KV)必须单独建策略,只授予确实需要注册的 agent。

步骤五:收敛暴露面

# 客户端 agent 只监听本地,DNS 也绑定本地,避免 8600 被内网任意主机当解析器滥用
{
  "client_addr": "127.0.0.1",
  "ports": { "dns": 8600, "http": -1, "https": -1 },
  "recursors": ["127.0.0.1"],
  "dns_config": { "allow_stale": true, "max_stale": "5s" }
}

另外确认 enable_script_checks 为 false(1.16 起默认已是 false),脚本型健康检查等于在 agent 上执行任意命令,能不开就不开。

步骤六:用 Intention 收口服务间通信

ACL 管的是”谁能读控制面”,并不解决”哪个服务能连哪个服务”。Consul Connect 的 intention 才是数据面的东西向授权:默认拒绝后,按”源服务 → 目标服务”逐条放行,未声明的调用直接失败,同时由 sidecar 完成双向 TLS,业务代码不需要改动一行。示例为 payment-api 只接受 order-web 调用:

consul config write - <<'EOF'
Kind = "service-intentions"
Name = "payment-api"
Sources = [
  { Name = "order-web", Action = "allow", Precedence = 9 },
  { Name = "*",         Action = "deny",  Precedence = 1 }
]
EOF

# 校验某条调用路径是否被允许
consul intention check order-web payment-api

# 列出所有生效的匹配结果,确认没有意外的 allow 条目
consul intention match -source order-web

Name = “*” 这条兜底规则必须显式写上。只写 allow 而不写兜底时,在 default deny 生效之前建立的连接会继续存活,intention 改完看起来生效了,实际旧连接仍在通行,这也是”改了策略但流量没断”的典型原因。

配置验证

# 1. 成员是否走加密通道(带 -tls-server-name 才会校验证书主机名)
consul members -tls-server-name=server.dc1.consul \
  -ca-file=/etc/consul.d/tls/ca.crt -http-addr=https://127.0.0.1:8501

# 2. 无 token 请求应返回 403
curl -s -o /dev/null -w '%{http_code}\n' \
  --cacert /etc/consul.d/tls/ca.crt https://127.0.0.1:8501/v1/catalog/services

# 3. 带 token 请求应返回 200 与服务列表
curl -s --cacert /etc/consul.d/tls/ca.crt \
  -H "X-Consul-Token: $CONSUL_HTTP_TOKEN" \
  https://127.0.0.1:8501/v1/catalog/services

# 4. 确认明文端口已不再监听
ss -lntp | grep consul

# 5. 确认 gossip 已加密(日志中不应出现 encryption disabled)
journalctl -u consul --no-pager | grep -iE 'encrypt|tls|error' | tail -20

验收标准:第 2 条必须返回 403,第 4 条只看到 8300/8301/8302/8501/8600 且 8500 不存在,第 5 条无 certificate signed by unknown authority 报错。

常见问题

Q1:开启 ACL 后服务注册返回 403 Permission denied 怎么办?

先定位是哪种权限缺失:注册服务需要 service_prefix 的 write,注册节点需要 node_prefix 的 write,反熵同步还需要 agent token。最稳妥的结构是给 agent 配一个只含本节点写权限的 token,写进配置的 acl.tokens.agent 字段,服务自身用只读 token。注意 down_policy: extend-cache 必须开启,否则 ACL 服务端不可达时所有服务调用会一起失败,把一次控制面抖动放大成全站故障。

Q2:gossip 加密密钥能直接替换吗?

不能直接改配置文件,那样会导致新旧节点互相无法解密、集群被拆成两个。正确流程是先 安装 新密钥到所有节点的 keyring,再 切换 主密钥,最后才 移除 旧密钥:

consul keyring -install "<新密钥>"
consul keyring -use "<新密钥>"
consul keyring -list
consul keyring -remove "<旧密钥>"

每一步之间留出足够时间让 gossip 传播完成,顺序错误会造成成员被判定为失败并触发选主。

Q3:开了 verify_server_hostname 后 members 报证书主机名不匹配?

该开关要求服务端证书的主机名严格等于 server.<dc>.consul 格式。证书用 consul tls cert create -server -dc=dc1 生成时主机名已是 server.dc1.consul,如果客户端仍报错,通常是 consul members 命令没有带 -tls-server-name 参数,CLI 默认按 IP 校验证书。生产环境应保留该开关为 true,它正是防止普通客户端证书冒充 server 拉取全量 Raft 日志的关键。

Q4:ACL 引导(bootstrap)提示 already initialized 怎么找回 token?

说明集群此前已引导过,token 一旦丢失无法通过重跑 bootstrap 找回——bootstrap 只在 ACL 系统为空时可用。此时需要用 server 本地的 reset 流程:停止所有 server,在每台上执行 consul acl bootstrap -reset 对应的数据目录清理(acl-bootstrap-reset 配置项),再重新引导。这是破坏性操作,务必先备份 /opt/consul/data 与 KV 快照。

总结

Consul 加固的顺序不能颠倒:先 gossip 加密保证成员可信,再 TLS 保证通道可信,最后 ACL 保证身份与权限可控。缺少任一环,前两环的投入都会被绕过。

  • encrypt 与两个 encrypt_verify_* 必须同时开启,否则仍接受明文成员
  • 三个 verify_* 全为 true,http: -1 关闭明文 API
  • default_policy: deny + down_policy: extend-cache 是生产必需组合
  • 一实例一 token,写权限单独授予,便于精准吊销
  • 密钥轮换走 install → use → remove 三步,不可直接改配置