侧边栏壁纸
  • 累计撰写 28 篇文章
  • 累计创建 23 个标签
  • 累计收到 0 条评论

目 录CONTENT

文章目录

apisix-go-plugin-runner插件开发,记录JWT session认证信息MD5值

zhanjie.me
2024-08-09 / 0 评论 / 0 点赞 / 25 阅读 / 0 字

之前写过一篇《记录session认证信息,并由logstash进行压缩处理》的笔记,其中篇尾也说明了该方案的缺点:日志占用空间大,logstash压力高,且因MD5值在Apisix外部计算得到,无法以其为key,进行接口限流操作。
后来需要以认证信息的MD5值为key,使用limit_rate插件进行接口访问限流,计算MD5值的操作只能在ingress(APISIX)层完成。
经过调研,结合运维技术栈,选择使用Go进行apisix-go-plugin-runner插件开发,官方开发教程

下图表左侧展示了 APISIX 的工作流程,而右侧的插件运行器负责运行用不同语言编写的外部插件。apisix-go-plugin-runner 就是这样一个支持 Go 语言的运行器。
结合APISIX的工作流程,我们需要在ext-plugin-pre-req进行rpc调用go-runner插件,并将计算后的MD5值赋值给新的header,传递给后面的 limit rate 等插件。

image-fmnjguue.png

开发环境

  • GO
    # go version
    go version go1.22.6 linux/amd64
    
  • APISIX
    # docker images|grep apisix
    apache/apisix             2.15.3-centos   34a2aed251a5   17 months ago   439MB
    
  • 准备实验环境
    # 1. 下载官方 docker compose 项目
    $ git clone https://github.com/apache/apisix-docker.git
    $ cd apisix-docker/example
    # 2. 修改apisix配置,使其默认端口为80/443
    $ cat docker-compose.yml
    ...
    apisix:
      image: apache/apisix:2.15.3-centos
      restart: always
      volumes:
        - ./apisix_log:/usr/local/apisix/logs
        - ./apisix_conf/config.yaml:/usr/local/apisix/conf/config.yaml:ro
      depends_on:
        - etcd
      ##network_mode: host
      ports:
        - "9180:9180/tcp"
        - "9091:9091/tcp"
        - "9092:9092/tcp"
        - "80:80/tcp"    # 修改这两项端口映射
        - "443:443/tcp"
      networks:
        apisix:
    ...
    $ cd apisix_conf/
    $ cat config.yaml
    ...
    apisix:
    node_listen: 80              # 修改该配置
    enable_ipv6: false
    
    ssl:                         # 此段需要添加
      enable: true
      listen:
        - port: 443
          enable_http2: true
      ssl_protocols: "TLSv1.2 TLSv1.3"
      ssl_ciphers: "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA:ECDHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES256-SHA256:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:DES-CBC3-SHA"
    ...
    $ 
    # 3. run docker compose
    $ docker-compose -p docker-apisix up -d
    # 4. check docker ps
    $  docker ps
    CONTAINER ID   IMAGE                                 COMMAND                   CREATED       STATUS       PORTS                                                                                                                                                                                                                                                                      NAMES
    895e8a3e8425   apache/apisix:2.15.3-centos           "/docker-entrypoint.…"   6 hours ago   Up 4 hours   0.0.0.0:80->80/tcp, :::80->80/tcp, 0.0.0.0:443->443/tcp, :::443->443/tcp, 0.0.0.0:9091-9092->9091-9092/tcp, :::9091-9092->9091-9092/tcp, 0.0.0.0:9180->9180/tcp, :::9180->9180/tcp                                      docker-apisix_apisix_1
    cdc4c422fcfb   nginx:1.19.0-alpine                   "/docker-entrypoint.…"   6 hours ago   Up 6 hours   0.0.0.0:9081->80/tcp, :::9081->80/tcp                                                                                                                                                                                                                                      docker-apisix_web1_1
    04f8874bf46a   grafana/grafana:7.3.7                 "/run.sh"                 6 hours ago   Up 6 hours   0.0.0.0:3000->3000/tcp, :::3000->3000/tcp                                                                                                                                                                                                                                  docker-apisix_grafana_1
    add5b20273c1   bitnami/etcd:3.4.15                   "/opt/bitnami/script…"   6 hours ago   Up 6 hours   0.0.0.0:2379->2379/tcp, :::2379->2379/tcp, 2380/tcp                                                                                                                                                                                                                        docker-apisix_etcd_1
    d9123f062765   prom/prometheus:v2.25.0               "/bin/prometheus --c…"   6 hours ago   Up 6 hours   0.0.0.0:9090->9090/tcp, :::9090->9090/tcp                                                                                                                                                                                                                                  docker-apisix_prometheus_1
    d9d159373372   apache/apisix-dashboard:2.13-alpine   "/usr/local/apisix-d…"   6 hours ago   Up 6 hours   0.0.0.0:9000->9000/tcp, :::9000->9000/tcp                                                                                                                                                                                                                                  docker-apisix_apisix-dashboard_1
    2e835683b790   nginx:1.19.0-alpine                   "/docker-entrypoint.…"   6 hours ago   Up 6 hours   0.0.0.0:9082->80/tcp, :::9082->80/tcp                                                                                                                                                                                                                                      docker-apisix_web2_1
    

部署完成,可以通过 localhost:9000 访问 dashboard
默认配置文件在 apisix-docker/example/apisix_conf 目录下。

代码开发

  • 代码结构
    $ cd apisix-go-plugin-runner
    $ tree cmd
    cmd
    └── go-runner
      ├── main.go
      ├── main_test.go
      ├── plugins                      # 主要在plugins目录下开发
      │   ├── md5_header.go              
      │   ├── md5_header_test.go
      │   └── mock_utils.go
      └── version.go
    
  • md5_header.go
    主要代码文件,其中重点实现 init()Name()ParseConf()RequestFilter()方法
    package plugins
    
    import (
      "crypto/md5"
      "encoding/hex"
      "encoding/json"
      "net/http"
    
      pkgHTTP "github.com/apache/apisix-go-plugin-runner/pkg/http"
      "github.com/apache/apisix-go-plugin-runner/pkg/log"
      "github.com/apache/apisix-go-plugin-runner/pkg/plugin"
    )
    
    const md5HeaderName = "md5-header"
    
    // 注册插件
    func init() {
      if err := plugin.RegisterPlugin(&Md5Header{}); err != nil {
      	log.Fatalf("failed to register plugin %s: %s", md5HeaderName, err.Error())
      }
    }
    
    // 定义Md5Header结构体,继承 plugin.DefaultPlugin
    type Md5Header struct {
      plugin.DefaultPlugin
    }
    
    // 定义Md5HeaderConfig结构体,为了从配置中读取 源header名称 ,和MD5计算后需赋值的 目标header名称
    type Md5HeaderConfig struct {
      SrcHeader string `json:"src_header"`
      DstHeader string `json:"dst_header"`
    }
    
    func (*Md5Header) Name() string {
      return md5HeaderName
    }
    
    // 读取JSON配置,并反序列化为Md5HeaderConfig实例
    func (p *Md5Header) ParseConf(in []byte) (interface{}, error) {
      conf := Md5HeaderConfig{}
      err := json.Unmarshal(in, &conf)
      if err != nil {
      	log.Errorf("failed to parse config for plugin %s: %s", p.Name(), err.Error())
      }
      return conf, err
    }
    
    // 处理Request的主要方法,MD5值计算在此实现
    func (*Md5Header) RequestFilter(conf interface{}, _ http.ResponseWriter, r pkgHTTP.Request) {
      cfg := conf.(Md5HeaderConfig)
      srcHeaderValue := r.Header().Get(cfg.SrcHeader)
      if srcHeaderValue != "" {
      	// 计算MD5
      	hashStr := md5.Sum([]byte(srcHeaderValue))
      	md5Str := hex.EncodeToString(hashStr[:])
    
          // 输出其中的16位
      	r.Header().Set(cfg.DstHeader, md5Str[8:24])
      }
    }
    
  • 单元测试,伪造request,对RequestFilter()进行测试
    • md5_header_test.go
    package plugins
    
    import (
      "github.com/stretchr/testify/require"
      "testing"
    )
    
    func TestRequestFilter(t *testing.T) {
      in := []byte(`{"src_header": "Authorization", "dst_header": "Logged-Id"}`)
      p := &Md5Header{}
      conf, err := p.ParseConf(in)
      require.NoError(t, err)
    
      req := &mockHTTPRequest{body: []byte("hello apisix")}
      req.setHeader()
      req.Header().Set("Authorization", "eyJhbGciOini982xMiJ9.eyJzdWIiOiJzZWxsZXJfMTA1NzE2NDc3MTUxMjQwX3BjXzE3MjA0Mjc2OTQ2MzkiLCJvcyI6InBjIiwic2NvcGVzIjpbXSwiaW5kdX89aclUeXBlIjoyLCJsb2dpblR5cGUiOjEsImp0aSI6ImVjMWQwODhkLWI1Y2Etabcd0987YmFlLTU4Y2MzZjc0NWJiNSIsImlhdCI6MTcyMDQyNzY5NCwiZXhwIjabcdzMDE5Njk0fQ.xzVh7UgKlnm85sigabcdYooUDtMeRSmGuR15abcdefghoKNrcAOD0cuXSx8KQ2lWjz3ztWO3Upw2_3Y98J4-Dw")
    
      p.RequestFilter(conf, nil, req)
      require.Equal(t, "b951e9d3d754215c", req.Header().Get("Logged-Id"))
    }
    
    
    • mock_utils.go
    package plugins
    
    import (
      "context"
      internalHTTP "github.com/apache/apisix-go-plugin-runner/internal/http"
      pkgHTTP "github.com/apache/apisix-go-plugin-runner/pkg/http"
      "net"
      "net/http"
      "net/url"
    )
    
    // mockHTTPRequest implements pkgHTTP.Request
    type mockHTTPRequest struct {
      body   []byte
      header *internalHTTP.Header
    }
    
    func (r *mockHTTPRequest) SetBody(body []byte) {
      r.body = body
    }
    
    func (r *mockHTTPRequest) setHeader() {
      r.header = &internalHTTP.Header{
      	Header: http.Header{},
      }
    }
    
    func (*mockHTTPRequest) Args() url.Values {
      panic("unimplemented")
    }
    
    func (r *mockHTTPRequest) Body() ([]byte, error) {
      return r.body, nil
    }
    
    func (*mockHTTPRequest) Context() context.Context {
      panic("unimplemented")
    }
    
    func (r *mockHTTPRequest) Header() pkgHTTP.Header {
      return r.header
    }
    
    func (*mockHTTPRequest) ID() uint32 {
      panic("unimplemented")
    }
    
    func (*mockHTTPRequest) Method() string {
      panic("unimplemented")
    }
    
    func (*mockHTTPRequest) Path() []byte {
      panic("unimplemented")
    }
    
    func (*mockHTTPRequest) RespHeader() http.Header {
      panic("unimplemented")
    }
    
    func (*mockHTTPRequest) SetPath([]byte) {
      panic("unimplemented")
    }
    
    func (*mockHTTPRequest) SrcIP() net.IP {
      panic("unimplemented")
    }
    
    func (*mockHTTPRequest) Var(string) ([]byte, error) {
      panic("unimplemented")
    }
    

代码构建

借助Makefile能很方便进行测试和构建,但要注意此文件中没有定义交叉平台编译选项,为了避免不必要的问题,选择和实际生产环境同平台进行构建。
此处选用 CentOS7(Linux/Amd64)平台编译构建

  • 提前安装go https://go.dev/doc/install
  • 提前安装gcc yum install -y gcc
  • 测试
    $ CGO_ENABLED=1 make test
    
  • 构建
    $ CGO_ENABLED=1 make build
    $ ls -l go-runner
    -rwxr-xr-x 1 root root 9424126 8月   8 13:11 go-runner
    

部署上线

  • 首先需要将go-runner可执行文件放到apisix容器里
    $ docker cp go-runner docker-apisix_apisix_1:/opt
    
  • 定义apisix配置,启用 go-runner
    $ cat config.yaml  #配置文件添加
    ...
    ext-plugin:
      cmd: ["/opt/go-runner", "run"]
    $ docker-compose -p docker-apisix restart apisix   #重启apisix
    
  • 在route中定义plugins配置,启用 md5-header 插件,并定义插件配置以传递给 Md5HeaderConfig
    {
      "plugins": {
        "ext-plugin-pre-req": {        // 通过ext-plugin-pre-req在apisix内置插件被调用前rpc调用 md5-header
          "_meta": {
            "disable": false
          },
          "conf": [
            {
              "name": "md5-header",
              "value": "{\"src_header\": \"Authorization\", \"dst_header\": \"Logged-Id\"}"     // 定义md5-header的配置
            }
          ]
        }
      }
    }
    
  • 可以通过 response-rewrite 插件将处理后的结果返回到响应头中,便于观察
    {
     "plugins": {
        "response-rewrite": {
          "disable": false,
          "headers": {
            "Authorization": "$http_authorization",
            "Logged-Id": "$http_logged_id"
          }
        }
      }
    }
    
  • 通过postman调用
    image-tzkvnhcb.png

日志记录

  • log_format定义记录 http_logged_id 字段
        access_log_format: "$http_x_forwarded_for $remote_addr \"-\" $remote_user [$time_local] $http_host \"$request\" $status $body_bytes_sent \"$http_referer\" \"$http_user_agent\" \"$upstream_addr\" $upstream_response_time $request_time \"$http_x_request_tag\" \"$http_Traceparent\" \"$http_logged_id\""
    
  • logstash定义grok表达式,转储成 user_agent.logged_id 字段
    filter {
      grok {
          patterns_dir => ["/usr/share/logstash/config/patterns"]
          match => [
             "message", "(?:%{NGINX_ADDRESS_LIST:nginx.access.remote_ip_list}) \"-\" (-|%{DATA:user.name}) \[%{HTTPDATE:nginx.access.time}\] (%{NGINX_HOST})? \"%{DATA:nginx.access.info}\" %{NUMBER:http.response.status_code:long} %{NUMBER:http.response.body.bytes:long} \"(-|%{DATA:http.request.referrer})\" \"(-|%{DATA:user_agent.original})\" \"(-|%{HOSTPORT:nginx.upstream.address})\" (-|%{NUMBER:nginx.upstream.response.time:float}) (-|%{NUMBER:nginx.request.time:float}) \"(-|%{DATA:http.header.x_request_tag})\"( \"00-%{DATA:Trace.Id}-%{DATA:Trace.SpanId}-01\")( \"(-|%{DATA:user_agent.logged_id})\")?"
          ]
      }
    }
    

此方案在ingress层进行MD5值计算,并由ext-plugin-pre-req控制在apisix内置插件前调用md5-header,可以保证内置插件可获取 $http_logged_id 变量,在apisix的很多traffic类型插件中,需要指定一个key进行规则限制。此变量可以在未进行consumer定义时,较简单直接的以用户粒度定位已登录的用户,而不仅仅是使用client ip进行定位。

0

评论区