TT Lab
开始
学习 学习路径 课程

Envoy 内部结构

两个授权服务器,两台 Envoy

在 TT Lab 中继续学习

目标

亲自用 Python 编写 HTTP 和 gRPC 两种方式的授权服务器,并启动分别向它们询问的 Envoy,测量头流向哪里,以及授权服务器挂掉时会发生什么。

为什么重要

如果把授权以代码的形式放进每个服务,有多少个服务就会产生多少种不同的授权。把判断集中到一处之后,这一处又会位于所有请求的路径上——如果没有决定那台服务器挂掉时是阻断还是放行,故障时就会在不知不觉中失去可用性和安全性之一。Istio 的 CUSTOM 授权在背后使用的也是这个过滤器,所以这里看到的头规则和失败行为,在网格中也会原样出现。

步骤

  1. 在 /root/envd-authz/authz_http.py 中编写监听 127.0.0.1:9191 的授权服务器,并在后台启动。如果是 Authorization: Bearer alice-token,就视为用户 alice,bob-token 则视为 bob,并返回 200 和响应头 x-auth-user: <사용자>(占位符为用户名);如果没有令牌或是未知的值,则返回 403 和响应头 x-deny-reason: missing-or-unknown-token。每个请求都向 /root/envd-authz/authz-http.log 追加一行 JSON(path、user、tenant(x-tenant 头的值)、allowed)。
  2. 启动上游 python3 /opt/lab/envoy/echo.py 8091(把收到的请求以 JSON 返回),并在 /root/envd-authz/envoy-http.yaml 中编写管理端口 9981、监听器 127.0.0.1:10081 的配置后启动(--base-id 11)。在 HTTP 过滤器链最前面放置 envoy.filters.http.ext_authz,设置 stat_prefix: http_authz,通过 http_service 向集群 authz_http(127.0.0.1:9191)询问。授权服务器给出的 x-auth-user 传给上游,x-deny-reason 随拒绝响应一起传给客户端,failure_mode_allow 为 false。
  3. 向监听器 10081 发送三个请求,把结果用四行写入 /root/envd-authz/03-http.txt——不带令牌发送的请求的状态码 no_token=,该响应中 x-deny-reason 的值 deny_reason=,同时发送 bob-token 和伪造的头 x-auth-user: mallory 时上游收到的 x-auth-user 的值 spoof_upstream=,以及在 alice-token 上附加 x-tenant: acme 的请求在授权服务器日志中留下的 tenant 值 http_saw_tenant=(没有值则为 none)。
  4. 在 /root/envd-authz/authz_grpc.py 中实现 envoy.service.auth.v3.Authorization/Check,并在 127.0.0.1:9192 上启动(用 /opt/xds/bin/python3 运行——这是一个装有 grpcio 和 Envoy 的 protobuf 的虚拟环境)。判断与 HTTP 版相同,但路径以 /public 开头时,无需令牌即放行,此时用户为 anonymous。放行时,在 OkHttpResponse 中放入头 x-auth-user;拒绝时,在 DeniedHttpResponse 中放入状态 403、头 x-deny-reason: grpc-missing-or-unknown-token 和正文。每个请求都向 /root/envd-authz/authz-grpc.log 留下同样格式的一行 JSON。
  5. 在 /root/envd-authz/envoy-grpc.yaml 中编写管理端口 9982、监听器 127.0.0.1:10082 的第二个 Envoy 并启动(--base-id 12)。ext_authz 设置 stat_prefix: grpc_authz,通过 grpc_service 的 envoy_grpc 调用集群 authz_grpc(127.0.0.1:9192,HTTP/2),并开启 failure_mode_allow: true 和 failure_mode_allow_header_add: true。第一个 Envoy 保持不动。
  6. 在 /root/envd-authz/envoy-grpc.yaml 中,把路径恰好为 /healthz 的路由添加到最前面,用正文 ok 直接返回 200(direct_response),并只在该路由上关闭 ext_authz(在 typed_per_filter_config 的 ExtAuthzPerRoute 中设置 disabled: true)。重新启动该 Envoy 后,不带令牌调用三次 /healthz。
  7. 在两个授权服务器都停止的状态下,向第一个 Envoy 发送带有 alice-token 的请求,向第二个 Envoy 发送不带令牌的 /orders 请求,在 /root/envd-authz/07-failure.txt 中写入 http_on_error=(第一个 Envoy 的状态码)、grpc_on_error=(第二个的状态码)、grpc_failure_header=(上游收到的 x-envoy-auth-failure-mode-allowed 值)三行。写完后重新启动两个授权服务器。
  8. 在 /root/envd-authz/08-report.md 中写入六行——fail_closed_code=、fail_open_code=(第 7 步的两个状态码)、spoofed_header_reached_upstream=(伪造的 mallory 到达了上游则为 yes)、http_authz_saw_tenant=、grpc_authz_saw_tenant=(各授权服务器日志中打印了 x-tenant 的值则为 yes)、healthz_asked_authz=(gRPC 授权服务器日志中有 /healthz 则为 yes)——并在下面至少写四行学到的内容。要确认 gRPC 一侧的值,请先向第二个 Envoy 发送一次附带 alice-token 和 x-tenant: acme 的请求。

参考

启动 HTTP 授权服务器

在 /root/envd-authz/authz_http.py 中编写监听 127.0.0.1:9191 的授权服务器,并在后台启动。如果是 Authorization: Bearer alice-token,就视为用户 alice,bob-token 则视为 bob,并返回 200 和响应头 x-auth-user: <사용자>(占位符为用户名);如果没有令牌或是未知的值,则返回 403 和响应头 x-deny-reason: missing-or-unknown-token。每个请求都向 /root/envd-authz/authz-http.log 追加一行 JSON(path、user、tenant(x-tenant 头的值)、allowed)。

在 ext_authz 的 HTTP 方式中,授权服务器就是一个普通的 Web 服务器。Envoy 以原请求的方法和路径重新发送请求,并把2xx 视为放行,其余视为拒绝。所以不只是 GET,对所有方法都必须做出同样的判断。标准库的 http.server 就足够了,启动时请用 setsid --fork nohup python3 … > 로그 2>&1 </dev/null 与 shell 脱离。评分器会直接向这台服务器发送三种令牌(alice、bob、没有)。

让 Envoy 对每个请求都询问授权服务器

启动上游 python3 /opt/lab/envoy/echo.py 8091(把收到的请求以 JSON 返回),并在 /root/envd-authz/envoy-http.yaml 中编写管理端口 9981、监听器 127.0.0.1:10081 的配置后启动(--base-id 11)。在 HTTP 过滤器链最前面放置 envoy.filters.http.ext_authz,设置 stat_prefix: http_authz,通过 http_service 向集群 authz_http(127.0.0.1:9191)询问。授权服务器给出的 x-auth-user 传给上游,x-deny-reason 随拒绝响应一起传给客户端,failure_mode_allow 为 false。

过滤器顺序就是处理顺序。ext_authz 必须位于 router 之前,被拒绝的请求才不会到达上游。授权服务器的响应头要传到哪里,由 authorization_response 的两个列表决定——allowed_upstream_headers 在放行时附加到上游请求,allowed_client_headers 在拒绝时附加到客户端响应。这个 Pod 中要启动多台 Envoy,所以请为 --base-id 设置不同的值,关闭时使用 curl -X POST localhost:9981/quitquitquit。

什么发给了授权服务器,什么发给了上游

向监听器 10081 发送三个请求,把结果用四行写入 /root/envd-authz/03-http.txt——不带令牌发送的请求的状态码 no_token=,该响应中 x-deny-reason 的值 deny_reason=,同时发送 bob-token 和伪造的头 x-auth-user: mallory 时上游收到的 x-auth-user 的值 spoof_upstream=,以及在 alice-token 上附加 x-tenant: acme 的请求在授权服务器日志中留下的 tenant 值 http_saw_tenant=(没有值则为 none)。

上游收到了什么,在 echo 返回的 JSON 的 headers 中。授权服务器收到了什么,在你自己留下的日志中。HTTP 方式的授权请求默认只携带 Host、Method、Path、Content-Length、Authorization,其他的头必须写在 allowed_headers 中才会传递。在相反的方向(授权服务器到上游)上,allowed_upstream_headers 会覆盖同名的头——也就是说,即使客户端伪造了身份头,到达上游的也是授权服务器指定的值。

启动 gRPC 授权服务器

在 /root/envd-authz/authz_grpc.py 中实现 envoy.service.auth.v3.Authorization/Check,并在 127.0.0.1:9192 上启动(用 /opt/xds/bin/python3 运行——这是一个装有 grpcio 和 Envoy 的 protobuf 的虚拟环境)。判断与 HTTP 版相同,但路径以 /public 开头时,无需令牌即放行,此时用户为 anonymous。放行时,在 OkHttpResponse 中放入头 x-auth-user;拒绝时,在 DeniedHttpResponse 中放入状态 403、头 x-deny-reason: grpc-missing-or-unknown-token 和正文。每个请求都向 /root/envd-authz/authz-grpc.log 留下同样格式的一行 JSON。

gRPC 方式下,Envoy 不重新发送请求,而是把请求的属性作为 CheckRequest 传递。头在 request.attributes.request.http.headers(小写名称的映射)中,路径在 .path 中。判定由 CheckResponse.status.code 决定(google.rpc.code_pb2.OK 即为放行)。模块路径是 envoy.service.auth.v3.external_auth_pb2 和 _pb2_grpc。评分器会直接向这台服务器发送三次 Check。

第二个 Envoy 用 gRPC 询问,授权服务器不在时放行

在 /root/envd-authz/envoy-grpc.yaml 中编写管理端口 9982、监听器 127.0.0.1:10082 的第二个 Envoy 并启动(--base-id 12)。ext_authz 设置 stat_prefix: grpc_authz,通过 grpc_service 的 envoy_grpc 调用集群 authz_grpc(127.0.0.1:9192,HTTP/2),并开启 failure_mode_allow: true 和 failure_mode_allow_header_add: true。第一个 Envoy 保持不动。

gRPC 只能运行在 HTTP/2 之上。如果不为集群启用 HTTP/2,Envoy 会以 HTTP/1.1 连接并失败,这个失败会被计为“授权服务器错误”——这台 Envoy 被设置为失败时放行,所以所有请求都会通过,很难察觉配置出错了。请在集群的 typed_extension_protocol_options 中放入 HttpProtocolOptions 的 explicit_http_config.http2_protocol_options。

健康检查路径不询问授权服务器

在 /root/envd-authz/envoy-grpc.yaml 中,把路径恰好为 /healthz 的路由添加到最前面,用正文 ok 直接返回 200(direct_response),并只在该路由上关闭 ext_authz(在 typed_per_filter_config 的 ExtAuthzPerRoute 中设置 disabled: true)。重新启动该 Envoy 后,不带令牌调用三次 /healthz。

负载均衡器的健康检查不知道令牌。如果对所有路径都启用授权,健康检查就会收到 403,负载均衡器会把好好的代理摘除。关闭过滤器不是过滤器配置的事,而是路由一侧的职责——让同一个过滤器在不同路径上有不同行为的机制,就是 typed_per_filter_config。关闭的路径完全不会去往授权服务器,所以授权服务器日志中不应该留下 /healthz。

授权服务器挂掉时——阻断一方和放行一方

在两个授权服务器都停止的状态下,向第一个 Envoy 发送带有 alice-token 的请求,向第二个 Envoy 发送不带令牌的 /orders 请求,在 /root/envd-authz/07-failure.txt 中写入 http_on_error=(第一个 Envoy 的状态码)、grpc_on_error=(第二个的状态码)、grpc_failure_header=(上游收到的 x-envoy-auth-failure-mode-allowed 值)三行。写完后重新启动两个授权服务器。

授权服务器没有响应时,Envoy 会根据配置选择两条路之一。阻断一方(fail closed)返回 status_on_error(默认 403),放行一方(fail open)则原样发送请求,需要的话还会附加标记头。无论哪种,统计中的 ext_authz.<stat_prefix>.error 都会上升,如果放行了,failure_mode_allowed 也会上升。停止服务器时,如果写成 pkill -f authz_http.py,包含该字符串的 shell 也可能一起被杀掉,所以请像 pkill -f '[a]uthz_http.py' 这样,把第一个字符用方括号括起来。

留下选择失败方向的依据

在 /root/envd-authz/08-report.md 中写入六行——fail_closed_code=、fail_open_code=(第 7 步的两个状态码)、spoofed_header_reached_upstream=(伪造的 mallory 到达了上游则为 yes)、http_authz_saw_tenant=、grpc_authz_saw_tenant=(各授权服务器日志中打印了 x-tenant 的值则为 yes)、healthz_asked_authz=(gRPC 授权服务器日志中有 /healthz 则为 yes)——并在下面至少写四行学到的内容。要确认 gRPC 一侧的值,请先向第二个 Envoy 发送一次附带 alice-token 和 x-tenant: acme 的请求。

值请取自证据文件和两个授权服务器的日志,而不是凭记忆。说明行中请用自己的话写下“授权服务器挂掉时,选择阻断会失去什么,选择放行又会失去什么”。评分器会读取同样的日志和文件来进行核对。