签发 SPIFFE 证书并搭建 STRICT 与 PERMISSIVE 链
目标
编写 PeerAuthentication,用 openssl 生成网格 CA 和 SPIFFE 证书,亲手建立各个模式对应的 Envoy 过滤器链,确认明文和 mTLS 如何分流,再从证书的 SAN 中推导出 AuthorizationPolicy 的 principal。
为什么重要
改为 STRICT 的那天就会中断的调用、想只把一个端口保留为明文却被悄悄忽略的策略、因为 principal 写错而让所有人都被拦截的授权策略——这三种问题,istioctl validate 都能通过。只要知道模式会变成 Envoy 的哪条链、身份来自证书的哪个位置,就能凭一行统计数据和一份证书找出原因。
步骤
- 在
/root/ist2-mtls/pa.yaml中编写 PeerAuthentication——apiVersion: security.istio.io/v1,名称reviews-strict,命名空间default,selector.matchLabels.app: reviews,mtls.mode: STRICT。把istioctl validate -f pa.yaml的输出和退出码保存到/root/ist2-mtls/01-validate.txt(最后一行为rc=)。 - 在
/root/ist2-mtls中用 openssl 创建一份自签名 CA(ca.crt、ca.key),并创建三份由该 CA 签名的证书——reviews.crt/reviews.key(服务器)、orders.crt/orders.key(要放行的客户端)、intruder.crt/intruder.key(同一个 CA 签名、但不放行的客户端)。每份证书的 SAN 只有一个 URI,即spiffe://cluster.local/ns/default/sa/<이름>(占位符为服务账号名称)。然后把实际从证书中读取到的 SAN,按reviews.crt=…、orders.crt=…、intruder.crt=…三行写入/root/ist2-mtls/02-ids.txt。 - 在
/root/ist2-mtls/strict.yaml中编写 Envoy 配置——管理端口9986,监听器virtualInbound监听127.0.0.1:10086,并设置监听器过滤器envoy.filters.listener.tls_inspector。过滤器链只有一条,即filter_chain_match.transport_protocol: tls,并在DownstreamTlsContext中设置require_client_certificate: true、服务器证书/root/ist2-mtls/reviews.crt和/root/ist2-mtls/reviews.key,以及受信任 CA/root/ist2-mtls/ca.crt(暂时不加入 SAN 匹配器)。这条链把所有路径发往集群inbound|8109||(127.0.0.1:8109)。以ok模式在8109上启动上游并启动 Envoy 之后,在/root/ist2-mtls/03-strict.txt中写入四行——plain=(明文http://localhost:10086/strict的状态码)、mtls=(用 orders 证书请求https://localhost:10086/strict的状态码)、mtls_body=(该响应的正文)、no_filter_chain_match=(统计listener.127.0.0.1_10086.no_filter_chain_match的值)。 - 把
/root/ist2-mtls/strict.yaml复制为/root/ist2-mtls/strict-san.yaml,然后只在这份副本中,往validation_context里再加入一个match_typed_subject_alt_names——san_type: URI,matcher.exact取第 2 步中读到的orders.crt的 SAN,原样填写。用这份配置重新启动 Envoy,并在/root/ist2-mtls/04-san.txt中写入三行——orders=(用 orders 证书请求https://localhost:10086/san的状态码)、intruder=(用 intruder 证书发出同样请求的状态码)、fail_verify_san=(统计listener.127.0.0.1_10086.ssl.fail_verify_san的值)。 - 以
/root/ist2-mtls/strict.yaml为基础创建/root/ist2-mtls/permissive.yaml——保留第 3 步的 TLS 链,再增加一条既没有filter_chain_match也没有transport_socket的明文链(同样发往集群inbound|8109||)。用这份配置重新启动 Envoy,并在/root/ist2-mtls/05-permissive.txt中写入三行——plain=(明文http://localhost:10086/permissive的状态码)、plain_body=(其正文)、mtls=(用 orders 证书请求https://localhost:10086/permissive的状态码)。 - 在
/root/ist2-mtls/pa-port.yaml中写两个文档——(1)Servicereviews(命名空间default,selectorapp: reviews,port: 80→targetPort: 10096),(2)PeerAuthenticationreviews-port(selector 相同,mtls.mode: STRICT,通过portLevelMtls对一个端口设置DISABLE)。该端口号应该写成什么,请看提示。然后在/root/ist2-mtls/portlevel.yaml中编写 Envoy 配置——让与第 3 步相同的监听器(10086)通过additional_addresses再监听127.0.0.1:10096,并设置一条filter_chain_match.destination_port: 10096的明文链,以及一条第 3 步的 TLS 链。重新启动 Envoy,并在/root/ist2-mtls/06-portlevel.txt中写入四行——validate_rc=(istioctl validate -f pa-port.yaml的退出码)、plain_10096=、plain_10086=(分别向各端口发送明文/port的状态码)、mtls_10086=(用 orders 证书请求https://localhost:10086/port的状态码)。 - 用 openssl 读取
/root/ist2-mtls/orders.crt的 SAN,并在/root/ist2-mtls/07-principal.txt中写入两行——san=(读到的 URI 原样)、principal=(要放进 AuthorizationPolicy 的principals的字符串)。然后在/root/ist2-mtls/authz.yaml中编写 AuthorizationPolicy——apiVersion: security.istio.io/v1,名称reviews-from-orders,命名空间default,selector.matchLabels.app: reviews,action: ALLOW,一条规则的from[0].source.principals中只放这一个 principal。istioctl validate -f authz.yaml必须通过。 - 在
/root/ist2-mtls/08-report.md中写入default_mode=(没有任何 PeerAuthentication 时的模式)、strict_plain=(第 3 步中明文请求收到的状态码)、permissive_chains=(第 5 步监听器的过滤器链数量)、orders_principal=(第 7 步的 principal)四行,并在下面写至少四行以-开头的说明。
参考
- 这个 Pod 中既没有真正的 istiod,也没有真正的 sidecar。所以无法用
istioctl proxy-config查看实际生成的内容,需要了解转换规则,并亲手编写等价的 Envoy 配置来确认行为。同样的规则会原样出现在生产集群的proxy-config输出中。 - 证书不是由真正的 istiod,而是由你们自己创建的 CA 签名。外形(SAN 中的 SPIFFE URI)与 istiod 签发的相同。
- 如果明文连接被断开,curl 会把状态码输出为
000。请用curl -s -o /dev/null -w '%{http_code}'获取状态码。 - 第 3、4、5、6 步的配置文件名称各不相同。不要修改前面步骤的文件,请用新名称创建。
- 启动 Envoy 时,请用
setsid --fork nohup envoy -c <파일> --log-level warn > <로그> 2>&1 </dev/null(占位符依次为配置文件与日志文件)使它与 shell 完全脱离。重新启动之前,用pkill -x envoy清理(pkill -f 'envoy -c'会把包含该字符串的 shell 自己也杀掉)。 - 镜像中有模拟上游的服务器:
python3 /opt/lab/envoy/upstream.py <포트> ok|fail|slow(占位符为端口)。响应正文为<모드>:<포트> <경로>(占位符依次为模式、端口与路径)。 - 修改配置之后,启动之前先用
envoy --mode validate -c <파일>(占位符为配置文件)筛查一遍。集群名称中含有|,所以在 YAML 中必须用引号括起来。
编写 STRICT PeerAuthentication,并离线筛查
在 /root/ist2-mtls/pa.yaml 中编写 PeerAuthentication——apiVersion: security.istio.io/v1,名称 reviews-strict,命名空间 default,selector.matchLabels.app: reviews,mtls.mode: STRICT。把 istioctl validate -f pa.yaml 的输出和退出码保存到 /root/ist2-mtls/01-validate.txt(最后一行为 rc=)。
PeerAuthentication 是接收方工作负载的配置。它不是对调用 reviews 的一方设置,而是对 reviews 自身设置“不要接收明文”,所以 selector 必须是接收方的标签。省略 selector,则作用于整个命名空间;放在根命名空间(istio-system)中,则作用于整个网格。istioctl validate 不需要集群,只检查 schema——如果模式值拼写错误,会在这里被发现。
创建网格 CA 和三份 SPIFFE 证书
在 /root/ist2-mtls 中用 openssl 创建一份自签名 CA(ca.crt、ca.key),并创建三份由该 CA 签名的证书——reviews.crt/reviews.key(服务器)、orders.crt/orders.key(要放行的客户端)、intruder.crt/intruder.key(同一个 CA 签名、但不放行的客户端)。每份证书的 SAN 只有一个 URI,即 spiffe://cluster.local/ns/default/sa/<이름>(占位符为服务账号名称)。然后把实际从证书中读取到的 SAN,按 reviews.crt=…、orders.crt=…、intruder.crt=… 三行写入 /root/ist2-mtls/02-ids.txt。
Istio 的身份不是名称(CN),而是 SAN 中的 URI——spiffe://<신뢰 도메인>/ns/<네임스페이스>/sa/<서비스 계정>(占位符依次为信任域、命名空间与服务账号)。istiod 确认 Pod 的服务账号令牌之后,签发的正是这种形状的证书。即使在 CSR 中用 -addext "subjectAltName=URI:…" 写入了 SAN,如果签名时没有给 openssl x509 -req 加上 -copy_extensions copyall,扩展也会被丢弃。签名之后,务必用 openssl x509 -noout -ext subjectAltName 确认,并用 openssl verify -CAfile ca.crt 查看证书链。
STRICT 会变成只有一条 TLS 链的监听器
在 /root/ist2-mtls/strict.yaml 中编写 Envoy 配置——管理端口 9986,监听器 virtualInbound 监听 127.0.0.1:10086,并设置监听器过滤器 envoy.filters.listener.tls_inspector。过滤器链只有一条,即 filter_chain_match.transport_protocol: tls,并在 DownstreamTlsContext 中设置 require_client_certificate: true、服务器证书 /root/ist2-mtls/reviews.crt 和 /root/ist2-mtls/reviews.key,以及受信任 CA /root/ist2-mtls/ca.crt(暂时不加入 SAN 匹配器)。这条链把所有路径发往集群 inbound|8109||(127.0.0.1:8109)。以 ok 模式在 8109 上启动上游并启动 Envoy 之后,在 /root/ist2-mtls/03-strict.txt 中写入四行——plain=(明文 http://localhost:10086/strict 的状态码)、mtls=(用 orders 证书请求 https://localhost:10086/strict 的状态码)、mtls_body=(该响应的正文)、no_filter_chain_match=(统计 listener.127.0.0.1_10086.no_filter_chain_match 的值)。
istiod 收到 STRICT 之后,会去掉入站监听器中的明文链。剩下的是只接收被 tls_inspector 判定为“这是 TLS”的连接的那条链,该链要求客户端证书,并用网格 CA 验证。明文连接没有可匹配的链,Envoy 直接将其关闭——连 HTTP 响应都没有,所以 curl 的状态码是 000,留下的痕迹就是 no_filter_chain_match 统计。服务器证书的 SAN 只有 URI,curl 的主机名检查无法通过,所以用 -k 只关闭对服务器的验证,同时用 --cert 和 --key 提交客户端证书。(Istio 的客户端不检查主机名,而是检查服务器 SAN 中的 SPIFFE ID。)
即使由同一个 CA 签名,SAN 不同也会被拒绝
把 /root/ist2-mtls/strict.yaml 复制为 /root/ist2-mtls/strict-san.yaml,然后只在这份副本中,往 validation_context 里再加入一个 match_typed_subject_alt_names——san_type: URI,matcher.exact 取第 2 步中读到的 orders.crt 的 SAN,原样填写。用这份配置重新启动 Envoy,并在 /root/ist2-mtls/04-san.txt 中写入三行——orders=(用 orders 证书请求 https://localhost:10086/san 的状态码)、intruder=(用 intruder 证书发出同样请求的状态码)、fail_verify_san=(统计 listener.127.0.0.1_10086.ssl.fail_verify_san 的值)。
第 3 步的链只检查“是不是网格 CA 签名的”。所以同样来自这个 CA 的 intruder 也通过了。加上 SAN 匹配器之后,除签名之外,还会检查“这是谁的证书”。不过,Istio 使用这个匹配器的地方主要是客户端一侧——由发起调用的一方确认“对方真的是 reviews 的身份吗”(secure naming);而在接收方,“谁可以调用”则由第 7 步的 AuthorizationPolicy 负责。这里是把同样的原理放在接收方,来观察 Envoy 如何比对 SAN。拒绝发生在 TLS 握手阶段,所以状态码是 000。
PERMISSIVE 是两条链
以 /root/ist2-mtls/strict.yaml 为基础创建 /root/ist2-mtls/permissive.yaml——保留第 3 步的 TLS 链,再增加一条既没有 filter_chain_match 也没有 transport_socket 的明文链(同样发往集群 inbound|8109||)。用这份配置重新启动 Envoy,并在 /root/ist2-mtls/05-permissive.txt 中写入三行——plain=(明文 http://localhost:10086/permissive 的状态码)、plain_body=(其正文)、mtls=(用 orders 证书请求 https://localhost:10086/permissive 的状态码)。
PERMISSIVE 的意思是“两种都接收”。在 Envoy 中,它表现为多加一条链。tls_inspector 查看第一个字节,如果是 TLS,就送往 transport_protocol: tls 链,否则送往没有匹配条件的那条链。Istio 实际生成的链还会附带 ALPN(istio-peer-exchange、istio)条件,但原理相同。如果给明文链加上 transport_socket,这条链也会等待 TLS,明文就又被拦住了。
portLevelMtls 是针对工作负载端口设置的一条链
在 /root/ist2-mtls/pa-port.yaml 中写两个文档——(1)Service reviews(命名空间 default,selector app: reviews,port: 80 → targetPort: 10096),(2)PeerAuthentication reviews-port(selector 相同,mtls.mode: STRICT,通过 portLevelMtls 对一个端口设置 DISABLE)。该端口号应该写成什么,请看提示。然后在 /root/ist2-mtls/portlevel.yaml 中编写 Envoy 配置——让与第 3 步相同的监听器(10086)通过 additional_addresses 再监听 127.0.0.1:10096,并设置一条 filter_chain_match.destination_port: 10096 的明文链,以及一条第 3 步的 TLS 链。重新启动 Envoy,并在 /root/ist2-mtls/06-portlevel.txt 中写入四行——validate_rc=(istioctl validate -f pa-port.yaml 的退出码)、plain_10096=、plain_10086=(分别向各端口发送明文 /port 的状态码)、mtls_10086=(用 orders 证书请求 https://localhost:10086/port 的状态码)。
iptables 在把到达 Pod 的连接转向 15006 时,会保留原始目标端口。这个端口不是 Service 的 port,而是容器实际监听的 targetPort,而 istiod 会把 portLevelMtls 的编号原样转换成以 destination_port 匹配的链。所以文档中也写作“工作负载端口”。另外,portLevelMtls 只有在带 selector 的策略中才会被接受——去掉之后,istioctl validate 会拒绝它。在这个 Pod 中没有 iptables,所以改为让一个监听器监听两个端口,来构造同样的形态。
从 SAN 中去掉 spiffe:// 就是 principal
用 openssl 读取 /root/ist2-mtls/orders.crt 的 SAN,并在 /root/ist2-mtls/07-principal.txt 中写入两行——san=(读到的 URI 原样)、principal=(要放进 AuthorizationPolicy 的 principals 的字符串)。然后在 /root/ist2-mtls/authz.yaml 中编写 AuthorizationPolicy——apiVersion: security.istio.io/v1,名称 reviews-from-orders,命名空间 default,selector.matchLabels.app: reviews,action: ALLOW,一条规则的 from[0].source.principals 中只放这一个 principal。istioctl validate -f authz.yaml 必须通过。
mTLS 结束之后,接收方 Envoy 会把对方证书的 URI SAN 记作这个连接的身份。AuthorizationPolicy 的 source.principals 就是与这个身份比对的,写法上要去掉 scheme(spiffe://),采用 <신뢰 도메인>/ns/<네임스페이스>/sa/<서비스 계정>(占位符依次为信任域、命名空间与服务账号)的形式。在 shell 中可以用 ${san#spiffe://} 去掉。注意——即使带着 scheme 写入,istioctl validate 也会通过。但这条策略不会匹配任何对象,如果是 ALLOW 策略,就会拦住所有人。而且 principal 只会出现在通过 mTLS 进入的连接上,所以在允许明文的 PERMISSIVE 端口上,没有任何请求会命中这条规则。
把各个模式在 Envoy 中产生的内容整理成一页
在 /root/ist2-mtls/08-report.md 中写入 default_mode=(没有任何 PeerAuthentication 时的模式)、strict_plain=(第 3 步中明文请求收到的状态码)、permissive_chains=(第 5 步监听器的过滤器链数量)、orders_principal=(第 7 步的 principal)四行,并在下面写至少四行以 - 开头的说明。
数值请从前面步骤的文件中抄录。说明中最好把“模式 → Envoy 中产生的内容 → 生产环境中看到的症状”成对写下来。至于默认模式为什么是那个值,可以回想一下往网格中依次加入 sidecar 的过程。