前言

ckman是一款由擎创科技数据库团队主导开发的,用来管理和监控ClickHouse的可视化开源工具。它将繁琐的ClickHouse集群的配置和监控工作变的简单化、直观化。github地址为ckman, 感兴趣的同学可以从此处下载源代码。

虽然ckman提供了web界面,但是对于部分企业来说,可能某些API的调用,直接使用网页端并不是那么的通用,而是更多的期望将其整合到自己的产品中去。比如通过自己的产品页面,就可以直接调用ckmanAPI,将ckman作为一个server端,从而很方便地进行集群管理,数据库表的管理以及数据监控。

为了安全考虑,ckman加入了https支持以及token进行鉴权。token的加入,虽然大大提高了数据链路的安全性,但是对于开发来说,却提供了一定难度(因为我们虽然能够通过登录的接口获取到token是什么,但是一旦ckman重启或者token过期,就会导致token失效)。

正是因为这种情况,ckmantoken之外,提供了另一种访问方案。只要请求在header中携带userToken的字段,且满足ckman规定的默认格式,就可以跳过token的鉴权机制,从而保证不会有token无效或过期自动跳转到登录页面的问题。

那么userToken是如何保证链路安全的呢?

在这里,ckman使用了RSA加密的技术。用户可以自己提供公钥和私钥,将userToken的内容使用私钥加密后,放入请求的header中,在ckman接收到请求的时候,一旦识别到header中有userToken的字段,就会使用对应的公钥去解密,从而建立有效的连接。

而我们仅仅要做的,就是将公钥配置到ckman的配置文件中即可。

server:
  id: 1
  port: 8808
  https: false
  pprof: true
  session_timeout: 3600
  public_key:  #该字段配置公钥

那么,userToken的默认格式是什么样的呢?

ckman提供的userToken是一个包含了时间戳、超时时间等字段的一个类(结构体),其结构如下所示:

type UserTokenModel struct {
    Duration           int64
    RandomPaddingValue string
    UserId             int64
    Timestamp          int64
}

其中,我们只需要关心DurationTimestamp字段即可。Duration指的是超时时间,ckman会将接收到请求的当前时间与userToken中携带的Timestamp时间进行比对,一旦时间间隔超过Duration,则认为超时。因此,这个超时时间是由client自己去控制的,自由度比较大。

代码演示

由于ckman是使用go语言开发的,所以我在这里也是用go进行演示。(事实上,只要符合以上规范,使用任何编程语言都能达到同样的效果)。

首先,我们在ckman的配置文件中配置公钥,关于如何生成RSA公钥,网上教程有很多,这里就不多赘述了。

ps:示例代码中的公钥和私钥来自github.com/housepower/ckman/common/rsa_test.go)

server:
  id: 1
  port: 8808
  https: false
  pprof: true
  session_timeout: 3600
  public_key: MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDNw9cbOh1JRVNf/pQiRRMoa4TSmgZeq9zyK+Z5qE0Ak1XcmFzRg1m667ZAgfl/gEiwMGtbKyiPBGeHP5Gw3z5ENIHg7WGKTE0yRM/U/FMnktjly2xzjf7HUl/IA7PFYq5KBVBNPhjwzuFxpmsJL+fhhuYB75uDL0axYwcm7WHdewIDAQAB

然后启动ckman,保持服务运行。

接下来就可以编写client端的代码了。

为了方便,我在这里直接引用ckman的包,只需要在控制台运行go get github.com/housepower/ckman 即可。当然如果觉得引入ckman的包太大,你也可以自己实现一套rsa通过私钥加密的算法,可参考github.com/housepower/ckman/common/rsa.go

代码示例如下:

package main

import (
    "encoding/json"
    "fmt"
    "io/ioutil"
    "net/http"
    "time"

    "github.com/housepower/ckman/common"
)

const PRI_KEY string = "MIICXgIBAAKBgQDNw9cbOh1JRVNf/pQiRRMoa4TSmgZeq9zyK+Z5qE0Ak1XcmFzRg1m667ZAgfl/gEiwMGtbKyiPBGeHP5Gw3z5ENIHg7WGKTE0yRM/U/FMnktjly2xzjf7HUl/IA7PFYq5KBVBNPhjwzuFxpmsJL+fhhuYB75uDL0axYwcm7WHdewIDAQABAoGBAIKxMz1t6hAR4mUEc95YdVSlBhYmEomrK4j97UO0bERDULPuanYAscuRz46lf21Gc+TEvEuJ3BcKux8id00aXpcbbNhqIDyUMvET4MjdisgXhxay/dzc6jRBYQdhMrLT0NYfQSbULdXA3CGQhti4nChazn708ag6slvjGtsC4O9BAkEA9c/ZmbKisBb3GweWP/IhYB+GO5Qsby0KkF582NgnGIjnpGirniO2jyNSXO72QerTfG4JXqofGkH7AmlO0bkX0QJBANZLFBzoRIJr8x32dsKnd/V/7k2OgNbrUGwFJrJOGCSClPF7yM3xjN0lg3EjKW4AZP75pr//vOLOYTQDHyeNv4sCQQCoUlzyJ2XJ6N/q7WYQgbAjD1MuxwcqVhBuzZT2NAWJgm4EofwqvM/M8mX651NPzgploT/fR+UmaNoGS7BCYlmRAkAFAY3/uuFW1qTAT3CozXa88ncjsq+J1cd0Lo6f3bksqSxHk+e1/+2VgPnYG8Us/69cUYK2u4ezGLUmnOgOaX5PAkEA6wwIjYGDQRYIEVD4oJyNtdL7FFso63lon3LMySxLgi/KZGS4N8+FYJQVIzWWCrdk3Z1mXw4wuOQkE4pDy8xx+w=="

func main() {
    //构建userToken结构体
    userTokenModel := common.UserTokenModel{
        Duration:           3600,                        //超时时间,这里设的是1个小时
        RandomPaddingValue: "ckmandemo",                 //随机值,eoi产品中使用的,ckman中用不到
        UserId:             123456,                      //用户id,eoi产品中可以设置不同的用户权限,ckman中用不到
        Timestamp:          time.Now().UnixNano() / 1e6, //当前时间,单位为毫秒
    }

    //将userToken结构体使用私钥加密
    uenc, _ := json.Marshal(userTokenModel)
    var rsaEncrypt common.RSAEncryption
    userToken, err := rsaEncrypt.Encode(uenc, PRI_KEY)
    if err != nil {
        fmt.Println("rsa encode error:", err)
        return
    }

    //创建http客户端
    client := http.Client{}

    //示例中我们调用/api/v1/ck/get 接口去获取ipv4集群的节点状态
    url := "http://localhost:8808/api/v1/ck/get/ipv4"
    req, err := http.NewRequest("GET", url, nil)
    if err != nil {
        fmt.Println("http request failed:", err)
        return
    }

    req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
    req.Header.Set("Content-type", "application/json")
    req.Header.Add("userToken", string(userToken)) //将userToken添加到header中,可以看到,这里并没有登录,也没有在header中添加token
    response, err := client.Do(req)
    if err != nil {
        fmt.Println("http error:", err)
        return
    }
    if response.StatusCode == 200 {
        defer response.Body.Close()
        body, err := ioutil.ReadAll(response.Body)
        if err != nil {
            fmt.Println("response error:", err)
            return
        }
        //打印返回结果
        fmt.Println(string(body))
    } else {
        fmt.Println("response:", response.Status)
        return
    }
}

运行结果如下所示:

chenyc@YenchangChan:ckmanDemo$go run main.go
{"retCode":0,"retMsg":"ok","entity":{"status":"green","version":"21.3.9.83","nodes":[{"ip":"192.168.21.51","hostname":"node1","status":"green","shardNumber":1,"replicaNumber":1,"disk":"36.95GB/49.98GB"},{"ip":"192.168.21.52","hostname":"node2","status":"green","shardNumber":1,"replicaNumber":2,"disk":"28.15GB/49.98GB"},{"ip":"192.168.21.53","hostname":"node3","status":"green","shardNumber":2,"replicaNumber":1,"disk":"28.71GB/49.98GB"},{"ip":"192.168.21.54","hostname":"node4","status":"green","shardNumber":2,"replicaNumber":2,"disk":"23.27GB/49.98GB"}],"mode":"deploy","needPassword":false}}

这与我们使用网页端访问的结果一致:

如果我们使用F12打开调试模式,会发现该接口返回的JSON与代码输出的JSON结果是一致的。

上面简单演示了GET请求,如果是POST或者其他请求,也是一样的操作,这里就不做演示了。关于ckman对外提供的接口,可以参考swagger文档。

开启swagger文档需要在配置文件增加swagger_enable配置项:

server:
  id: 1
  port: 8808
  https: false
  pprof: true
  swagger_enable: true    #配置此项即可使用swagger文档
  session_timeout: 3600
  public_key: MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDNw9cbOh1JRVNf/pQiRRMoa4TSmgZeq9zyK+Z5qE0Ak1XcmFzRg1m667ZAgfl/gEiwMGtbKyiPBGeHP5Gw3z5ENIHg7WGKTE0yRM/U/FMnktjly2xzjf7HUl/IA7PFYq5KBVBNPhjwzuFxpmsJL+fhhuYB75uDL0axYwcm7WHdewIDAQAB

重启后可登录http://localhost:8808/swagger... 进行查看。界面如下所示:

总结

本文简单介绍了如何在实际开发中使用代码操作ckman,可以通过配置RSA公钥和私钥的方式跳过token鉴权,这样有助于将ckman的调用与企业自己的产品相结合,使得ckman不仅仅是一款独立的管理工具,更是扮演了一个server服务端的角色,从而大大扩展了ckman的使用场景。

03-05 21:19