K8S自定義Webhook實(shí)現(xiàn)認(rèn)證管理
大家好,我是喬克。
在Kubernetes中,APIServer是整個(gè)集群的中樞神經(jīng),它不僅連接了各個(gè)模塊,更是為整個(gè)集群提供了訪問控制能力。
Kubernetes API的每個(gè)請(qǐng)求都要經(jīng)過多階段的訪問控制才會(huì)被接受,包括認(rèn)證、授權(quán)、準(zhǔn)入,如下所示。
客戶端(普通賬戶、ServiceAccount等)想要訪問Kubernetes中的資源,需要通過經(jīng)過APIServer的三大步驟才能正常訪問,三大步驟如下:
- Authentication 認(rèn)證階段:判斷請(qǐng)求用戶是否為能夠訪問集群的合法用戶。如果用戶是個(gè)非法用戶,那 apiserver會(huì)返回一個(gè) 401 的狀態(tài)碼,并終止該請(qǐng)求;
- 如果用戶合法的話,我們的 apiserver 會(huì)進(jìn)入到訪問控制的第二階段 Authorization:授權(quán)階段。在該階段中apiserver 會(huì)判斷用戶是否有權(quán)限進(jìn)行請(qǐng)求中的操作。如果無權(quán)進(jìn)行操作,apiserver 會(huì)返回 403的狀態(tài)碼,并同樣終止該請(qǐng)求;
- 如果用戶有權(quán)進(jìn)行該操作的話,訪問控制會(huì)進(jìn)入到第三個(gè)階段:AdmissionControl。在該階段中 apiserver 的admission controller 會(huì)判斷請(qǐng)求是否是一個(gè)安全合規(guī)的請(qǐng)求。如果最終驗(yàn)證通過的話,訪問控制流程才會(huì)結(jié)束。
這篇文章主要和大家討論認(rèn)證環(huán)節(jié)。
認(rèn)證
Kubernetes中支持多種認(rèn)證機(jī)制,也支持多種認(rèn)證插件,在認(rèn)證過程中,只要一個(gè)通過則表示認(rèn)證通過。
常用的認(rèn)證插件有:
- X509證書
- 靜態(tài)Token
- ServiceAccount
- OpenID
- Webhook
- .....
這里不會(huì)把每種認(rèn)證插件都介紹一下,主要講講Webhook的使用場(chǎng)景。
在企業(yè)中,大部分都會(huì)有自己的賬戶中心,用于管理員工的賬戶以及權(quán)限,而在K8s集群中,也需要進(jìn)行賬戶管理,如果能直接使用現(xiàn)有的賬戶系統(tǒng)是不是會(huì)方便很多?
K8s的Webhook就可以實(shí)現(xiàn)這種需求,Webhook是一個(gè)HTTP回調(diào),通過一個(gè)條件觸發(fā)HTTP POST請(qǐng)求發(fā)送到Webhook 服務(wù)端,服務(wù)端根據(jù)請(qǐng)求數(shù)據(jù)進(jìn)行處理。
下面就帶大家從0到1開發(fā)一個(gè)認(rèn)證服務(wù)。
開發(fā)Webhook
簡(jiǎn)介
WebHook的功能主要是接收APIServer的認(rèn)證請(qǐng)求,然后調(diào)用不同的認(rèn)證服務(wù)進(jìn)行認(rèn)證,如下所示。
這里只是做一個(gè)Webhook的例子,目前主要實(shí)現(xiàn)了Github和LDAP認(rèn)證,當(dāng)然,認(rèn)證部分的功能比較單一,沒有考慮復(fù)雜的場(chǎng)景。
Webhook開發(fā)
開發(fā)環(huán)境
構(gòu)建符合規(guī)范的Webhook
在開發(fā)Webhook的時(shí)候,需要符合Kubernetes的規(guī)范,具體如下:
- URL:https://auth.example.com/auth
- Method:POST
- Input參數(shù)
- {
- "apiVersion": "authentication.k8s.io/v1beta1",
- "kind": "TokenReview",
- "spec": {
- "token": "<持有者令牌>"
- }
- }
- Output參數(shù)
如果成功會(huì)返回:
- {
- "apiVersion": "authentication.k8s.io/v1beta1",
- "kind": "TokenReview",
- "status": {
- "authenticated": true,
- "user": {
- "username": "janedoe@example.com",
- "uid": "42",
- "groups": [
- "developers",
- "qa"
- ],
- "extra": {
- "extrafield1": [
- "extravalue1",
- "extravalue2"
- ]
- }
- }
- }
- }
如果不成功,會(huì)返回:
- {
- "apiVersion": "authentication.k8s.io/v1beta1",
- "kind": "TokenReview",
- "status": {
- "authenticated": false
- }
- }
遠(yuǎn)程服務(wù)應(yīng)該會(huì)填充請(qǐng)求的 status 字段,以標(biāo)明登錄操作是否成功。
開發(fā)認(rèn)證服務(wù)
(1)創(chuàng)建項(xiàng)目并初始化go mod
- # mkdir kubernetes-auth-webhook
- # cd kubernetes-auth-webhook
- # go mod init
(2)在項(xiàng)目根目錄下創(chuàng)建webhook.go,寫入如下內(nèi)容
- package main
- import (
- "encoding/json"
- "github.com/golang/glog"
- authentication "k8s.io/api/authentication/v1beta1"
- "k8s.io/klog/v2"
- "net/http"
- "strings"
- )
- type WebHookServer struct {
- server *http.Server
- }
- func (ctx *WebHookServer) serve(w http.ResponseWriter, r *http.Request) {
- // 從APIServer中取出body
- // 將body進(jìn)行拆分, 取出type
- // 根據(jù)type, 取出不同的認(rèn)證數(shù)據(jù)
- var req authentication.TokenReview
- decoder := json.NewDecoder(r.Body)
- err := decoder.Decode(&req)
- if err != nil {
- klog.Error(err, "decoder request body error.")
- req.Status = authentication.TokenReviewStatus{Authenticated: false}
- w.WriteHeader(http.StatusUnauthorized)
- _ = json.NewEncoder(w).Encode(req)
- return
- }
- // 判斷token是否包含':'
- // 如果不包含,則返回認(rèn)證失敗
- if !(strings.Contains(req.Spec.Token, ":")) {
- klog.Error(err, "token invalied.")
- req.Status = authentication.TokenReviewStatus{Authenticated: false}
- //req.Status = map[string]interface{}{"authenticated": false}
- w.WriteHeader(http.StatusUnauthorized)
- _ = json.NewEncoder(w).Encode(req)
- return
- }
- // split token, 獲取type
- tokenSlice := strings.SplitN(req.Spec.Token, ":", -1)
- glog.Infof("tokenSlice: ", tokenSlice)
- hookType := tokenSlice[0]
- switch hookType {
- case "github":
- githubToken := tokenSlice[1]
- err := authByGithub(githubToken)
- if err != nil {
- klog.Error(err, "auth by github error")
- req.Status = authentication.TokenReviewStatus{Authenticated: false}
- w.WriteHeader(http.StatusUnauthorized)
- _ = json.NewEncoder(w).Encode(req)
- return
- }
- klog.Info("auth by github success")
- req.Status = authentication.TokenReviewStatus{Authenticated: true}
- w.WriteHeader(http.StatusOK)
- _ = json.NewEncoder(w).Encode(req)
- return
- case "ldap":
- username := tokenSlice[1]
- password := tokenSlice[2]
- err := authByLdap(username, password)
- if err != nil {
- klog.Error(err, "auth by ldap error")
- req.Status = authentication.TokenReviewStatus{Authenticated: false}
- //req.Status = map[string]interface{}{"authenticated": false}
- w.WriteHeader(http.StatusUnauthorized)
- _ = json.NewEncoder(w).Encode(req)
- return
- }
- klog.Info("auth by ldap success")
- req.Status = authentication.TokenReviewStatus{Authenticated: true}
- //req.Status = map[string]interface{}{"authenticated": true}
- w.WriteHeader(http.StatusOK)
- _ = json.NewEncoder(w).Encode(req)
- return
- }
- }
主要是解析認(rèn)證的請(qǐng)求Token,然后將Token進(jìn)行拆分判斷是需要什么認(rèn)證,Token的樣例如下:
- Github認(rèn)證:github:
- LDAP認(rèn)證:ldap::
這樣就可以獲取到用戶想用哪種認(rèn)證,再掉具體的認(rèn)證服務(wù)進(jìn)行處理。
(3)創(chuàng)建github.go,提供github認(rèn)證方法
- package main
- import (
- "context"
- "github.com/golang/glog"
- "github.com/google/go-github/github"
- "golang.org/x/oauth2"
- )
- func authByGithub(token string) (err error) {
- glog.V(2).Info("start auth by github......")
- tokenSource := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token})
- tokenClient := oauth2.NewClient(context.Background(), tokenSource)
- githubClient := github.NewClient(tokenClient)
- _, _, err = githubClient.Users.Get(context.Background(), "")
- if err != nil {
- return err
- }
- return nil
- }
可以看到,這里僅僅做了一個(gè)簡(jiǎn)單的Token認(rèn)證,認(rèn)證的結(jié)果比較粗暴,如果err=nil,則表示認(rèn)證成功。
(4)創(chuàng)建ldap.go,提供ldap認(rèn)證
- package main
- import (
- "crypto/tls"
- "errors"
- "fmt"
- "github.com/go-ldap/ldap/v3"
- "github.com/golang/glog"
- "k8s.io/klog/v2"
- "strings"
- )
- var (
- ldapUrl = "ldap://" + "192.168.100.179:389"
- )
- func authByLdap(username, password string) error {
- groups, err := getLdapGroups(username, password)
- if err != nil {
- return err
- }
- if len(groups) > 0 {
- return nil
- }
- return fmt.Errorf("No matching group or user attribute. Authentication rejected, Username: %s", username)
- }
- // 獲取user的groups
- func getLdapGroups(username, password string) ([]string, error) {
- glog.Info("username:password", username, ":", password)
- var groups []string
- config := &tls.Config{InsecureSkipVerify: true}
- ldapConn, err := ldap.DialURL(ldapUrl, ldap.DialWithTLSConfig(config))
- if err != nil {
- glog.V(4).Info("dial ldap failed, err: ", err)
- return groups, err
- }
- defer ldapConn.Close()
- binduser := fmt.Sprintf("CN=%s,ou=People,dc=demo,dc=com", username)
- err = ldapConn.Bind(binduser, password)
- if err != nil {
- klog.V(4).ErrorS(err, "bind user to ldap error")
- return groups, err
- }
- // 查詢用戶成員
- searchString := fmt.Sprintf("(&(objectClass=person)(cn=%s))", username)
- memberSearchAttribute := "memberOf"
- searchRequest := ldap.NewSearchRequest(
- "dc=demo,dc=com",
- ldap.ScopeWholeSubtree,
- ldap.NeverDerefAliases,
- 0,
- 0,
- false,
- searchString,
- []string{memberSearchAttribute},
- nil,
- )
- searchResult, err := ldapConn.Search(searchRequest)
- if err != nil {
- klog.V(4).ErrorS(err, "search user properties error")
- return groups, err
- }
- // 如果沒有查到結(jié)果,返回失敗
- if len(searchResult.Entries[0].Attributes) < 1 {
- return groups, errors.New("no user in ldap")
- }
- entry := searchResult.Entries[0]
- for _, e := range entry.Attributes {
- for _, attr := range e.Values {
- groupList := strings.Split(attr, ",")
- for _, g := range groupList {
- if strings.HasPrefix(g, "cn=") {
- group := strings.Split(g, "=")
- groups = append(groups, group[1])
- }
- }
- }
- }
- return groups, nil
- }
這里的用戶名是固定了的,所以不適合其他場(chǎng)景。
(5)創(chuàng)建main.go入口函數(shù)
- package main
- import (
- "context"
- "flag"
- "fmt"
- "github.com/golang/glog"
- "net/http"
- "os"
- "os/signal"
- "syscall"
- )
- var port string
- func main() {
- flag.StringVar(&port, "port", "9999", "http server port")
- flag.Parse()
- // 啟動(dòng)httpserver
- wbsrv := WebHookServer{server: &http.Server{
- Addr: fmt.Sprintf(":%v", port),
- }}
- mux := http.NewServeMux()
- mux.HandleFunc("/auth", wbsrv.serve)
- wbsrv.server.Handler = mux
- // 啟動(dòng)協(xié)程來處理
- go func() {
- if err := wbsrv.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
- glog.Errorf("Failed to listen and serve webhook server: %v", err)
- }
- }()
- glog.Info("Server started")
- // 優(yōu)雅退出
- signalChan := make(chan os.Signal, 1)
- signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM)
- <-signalChan
- glog.Infof("Got OS shutdown signal, shutting down webhook server gracefully...")
- _ = wbsrv.server.Shutdown(context.Background())
- }
到此整個(gè)認(rèn)證服務(wù)就開發(fā)完畢了,是不是很簡(jiǎn)單?
Webhook測(cè)試
APIServer添加認(rèn)證服務(wù)
使用Webhook進(jìn)行認(rèn)證,需要在kube-apiserver里開啟,參數(shù)如下:
- --authentication-token-webhook-config-file 指向一個(gè)配置文件,其中描述 如何訪問遠(yuǎn)程的 Webhook 服務(wù)
- --authentication-token-webhook-config-file 指向一個(gè)配置文件,其中描述 如何訪問遠(yuǎn)程的 Webhook 服務(wù)
配置文件使用 kubeconfig 文件的格式。文件中,clusters 指代遠(yuǎn)程服務(wù),users 指代遠(yuǎn)程 API 服務(wù) Webhook。配置如下:
(1)、將配置文件放到相應(yīng)的目錄
- # mkdir /etc/kubernetes/webhook
- # cat >> webhook-config.json <EOF
- {
- "kind": "Config",
- "apiVersion": "v1",
- "preferences": {},
- "clusters": [
- {
- "name": "github-authn",
- "cluster": {
- "server": "http://10.0.4.9:9999/auth"
- }
- }
- ],
- "users": [
- {
- "name": "authn-apiserver",
- "user": {
- "token": "secret"
- }
- }
- ],
- "contexts": [
- {
- "name": "webhook",
- "context": {
- "cluster": "github-authn",
- "user": "authn-apiserver"
- }
- }
- ],
- "current-context": "webhook"
- }
- EOF
(2)在kube-apiserver中添加配置參數(shù)
- # mkdir /etc/kubernetes/backup
- # cp /etc/kubernetes/manifests/kube-apiserver.yaml /etc/kubernetes/backup/kube-apiserver.yaml
- # cd /etc/kubernetes/manifests/
- # cat kube-apiserver.yaml
- apiVersion: v1
- kind: Pod
- metadata:
- annotations:
- kubeadm.kubernetes.io/kube-apiserver.advertise-address.endpoint: 10.0.4.9:6443
- creationTimestamp: null
- labels:
- component: kube-apiserver
- tier: control-plane
- name: kube-apiserver
- namespace: kube-system
- spec:
- containers:
- - command:
- - kube-apiserver
- - ......
- - --authentication-token-webhook-config-file=/etc/config/webhook-config.json
- image: registry.cn-hangzhou.aliyuncs.com/google_containers/kube-apiserver:v1.22.0
- imagePullPolicy: IfNotPresent
- ......
- volumeMounts:
- ......
- - name: webhook-config
- mountPath: /etc/config
- readOnly: true
- hostNetwork: true
- priorityClassName: system-node-critical
- securityContext:
- seccompProfile:
- type: RuntimeDefault
- volumes:
- ......
- - hostPath:
- path: /etc/kubernetes/webhook
- type: DirectoryOrCreate
- name: webhook-config
- status: {}
ps: 為了節(jié)約篇幅,上面省略了部分配置。
當(dāng)修改完過后,kube-apiserver會(huì)自動(dòng)重啟。
測(cè)試Github認(rèn)證
(1)在github上獲取Token,操作如圖所示
(2)配置kubeconfig,添加user
- # cat ~/.kube/config
- apiVersion: v1
- ......
- users:
- - name: joker
- user:
- token: github:ghp_jevHquU4g43m46nczWS0ojxxxxxxxxx
(3)用Joker用戶進(jìn)行訪問
返回結(jié)果如下,至于報(bào)錯(cuò)是因?yàn)橛脩舻臋?quán)限不足。
- # kubectl get po --user=joker
- Error from server (Forbidden): pods is forbidden: User "" cannot list resource "pods" in API group "" in the namespace "default"
可以在webhook上看到日志信息,如下:
- # ./kubernetes-auth-webhook
- I1207 15:37:29.531502 21959 webhook.go:55] auth by github success
從日志和結(jié)果可以看到,使用Github認(rèn)證是OK的。
測(cè)試LDAP認(rèn)證
LDAP簡(jiǎn)介
LDAP是協(xié)議,不是軟件。
LDAP是輕量目錄訪問協(xié)議,英文全稱是Lightweight Directory Access Protocol,一般都簡(jiǎn)稱為L(zhǎng)DAP。按照我們對(duì)文件目錄的理解,ldap可以看成一個(gè)文件系統(tǒng),類似目錄和文件樹。
OpenLDAP是常用的服務(wù)之一,也是我們本次測(cè)試的認(rèn)證服務(wù)。
安裝OpenLDAP
OpenLDAP的安裝方式有很多,可以使用容器部署,也可以直接安裝在裸機(jī)上,這里采用后者。
- # yum install -y openldap openldap-clients openldap-servers
- # systemctl start slapd
- # systemctl enable slapd
默認(rèn)配置文件,位于/etc/openldap/slapd.d, 文件格式為L(zhǎng)DAP Input Format (LDIF), ldap目錄特定的格式。這里不對(duì)配置文件做太多的介紹,有興趣可以自己去學(xué)習(xí)學(xué)習(xí)【1】。
在LDAP上配置用戶
(1)導(dǎo)入模板
- ldapadd -Y EXTERNAL -H ldapi:/// -f /etc/openldap/schema/cosine.ldif
- ldapadd -Y EXTERNAL -H ldapi:/// -f /etc/openldap/schema/nis.ldif
- ldapadd -Y EXTERNAL -H ldapi:/// -f /etc/openldap/schema/inetorgperson.ldif
(2)創(chuàng)建base組織
- # cat base.ldif
- dn: dc=demo,dc=com
- objectClass: top
- objectClass: dcObject
- objectClass: organization
- o: ldap測(cè)試組織
- dc: demo
- dn: cn=Manager,dc=demo,dc=com
- objectClass: organizationalRole
- cn: Manager
- description: 組織管理人
- dn: ou=People,dc=demo,dc=com
- objectClass: organizationalUnit
- ou: People
- dn: ou=Group,dc=demo,dc=com
- objectClass: organizationalUnit
- ou: Group
使用ldapadd添加base。
- ldapadd -x -D cn=admin,dc=demo,dc=com -w admin -f base.ldif
(3)添加成員
- # cat adduser.ldif
- dn: cn=jack,ou=People,dc=demo,dc=com
- changetype: add
- objectClass: inetOrgPerson
- cn: jack
- departmentNumber: 1
- title: 大牛
- userPassword: 123456
- sn: Bai
- mail: jack@demo.com
- displayName: 中文名
使用ldapadd執(zhí)行添加。
- ldapadd -x -D cn=admin,dc=demo,dc=com -w admin -f adduser.ldif
(4)將用戶添加到組
- # cat add_member_group.ldif
- dn: cn=g-admin,ou=Group,dc=demo,dc=com
- changetype: modify
- add: member
- member: cn=jack,ou=People,dc=demo,dc=com
使用ldapadd執(zhí)行添加。
- ldapadd -x -D cn=admin,dc=demo,dc=com -w admin -f add_member_group.ldif
配置kubeconfig,進(jìn)行l(wèi)dap認(rèn)證測(cè)試
(1)修改~/.kube/config配置文件
- # cat ~/.kube/config
- apiVersion: v1
- ......
- users:
- - name: joker
- user:
- token: github:ghp_jevHquU4g43m46nczWS0oxxxxxxxx
- - name: jack
- user:
- token: ldap:jack:123456
(2)使用kubectl進(jìn)行測(cè)試
- # kubectl get po --user=jack
- Error from server (Forbidden): pods is forbidden: User "" cannot list resource "pods" in API group "" in the namespace "default"
webhook服務(wù)日志如下:
- # ./kubernetes-auth-webhook
- I1207 16:09:09.292067 7605 webhook.go:72] auth by ldap success
通過測(cè)試結(jié)果可以看到使用LDAP認(rèn)證測(cè)試成功。
總結(jié)
使用Webhook可以很靈活的將K8S的租戶和企業(yè)內(nèi)部賬戶系統(tǒng)進(jìn)行打通,這樣可以方便管理用戶賬戶。
不過上面開發(fā)的Webhook只是一個(gè)簡(jiǎn)單的例子,驗(yàn)證方式和手法都比較粗暴,CoreOS開源的Dex【2】是比較不錯(cuò)的產(chǎn)品,可以直接使用。
本文轉(zhuǎn)載自微信公眾號(hào)「運(yùn)維開發(fā)故事」