实现一个代理Kerberos环境部分组件控制台的Web服务
背景
首先要说明下我们目前有部分集群的环境使用的是HDP-3.1.5.0的大数据集群,除了集成了一些自定义的服务以外,没有对组件做二次开发。
安全措施引入的问题
生产环境部署的集群由于安全需求,一般都必须打开Kerberos认证,有的客户会要求同时开启https访问,而且不允许关闭web访问的安全认证。这种情况下,如果直接在浏览器访问组件的控制台,就会提示输入用户名密码进行登录或者直接报403:
而且一般来说我们用户只能拿到keytabs文件,拿不到用户名密码信息;一种常见的解决方式是安装KIT工具,这样浏览器就可以通过keytab进行认证了,不过只要涉及到在自己电脑上装多余的东西就很难受。
有的客户会提供单独的Windows堡垒机,堡垒机上安装好需要的插件、客户端,然后需要访问webUI的时候就登录堡垒机进行。
SSO单点登录
有的公司具备较强的研发实力,能够基于组件去开发统一的单点登录程序,这种确实很牛逼,但是一般小公司不具备这种实力;开源解决方案里,唯一可选的只有Knox,用过都觉得难受,如果对Knox以及认证流程不熟悉的话,会把自己玩死。而且Knox的方便是有前提的,那就是能够配置好,配置好了以后做单点访问确实很方便。
基于此,当然还有很多其他的原因,最终我想自己做一个简单的web服务,通过反向代理等手段实现一般用户对开启Kerberos的hdfs、yarn、hbase环境的控制台访问。
过程
整体设计
功能大概分为这几点:
- 代理hdfs、yarn、hbase三种服务的控制台UI
- hdfs和hbase的web可以不考虑主备,因为正常情况下也是都能访问的
- yarn的web在访问到备节点时会跳转到主节点,这个要做一下处理,不然会出现页面找不到
- 通过单一代理服务同时能够访问多个服务,所以需要对路由做区分,这就对代理转发有一些特殊要求
- 请求过程中要完成kerberos认证设置SPNEGO请求认证头,以及TLS的配置
- 由于将安全链接代理出来了,为了防止引入新的安全问题,还需要一个简易的登录逻辑
整体来说上述的几点就是程序的主要功能点和需求点了。
路由
web服务我使用Gin框架,路由的设置单独放在一个初始化函数initRoute
中,因为服务很小,就直接用Gin自带的模板功能渲染html页面了,所以/login
路由需要有GET和POST两个接口,一个用来渲染模板页,一个用来点击登录提交信息:
route.GET("/login", func(ctx *gin.Context) {
ctx.HTML(http.StatusOK, "login.html", gin.H{})
})
route.POST("/login", Login)
// 注销按钮,注销后清空session信息
route.POST("/signout", SignOut)
// 主页,根据组件服务信息渲染页面
route.GET("/index", AuthMiddler, func(ctx *gin.Context) {
ctx.HTML(http.StatusOK, "index.html", gin.H{
"nns": config.Y.Namenode.Servers,
"nn_port": config.Y.Namenode.Port,
"rms": config.Y.ResourceManager.Servers,
"rm_port": config.Y.ResourceManager.Port,
"hms": config.Y.HbaseMaster.Servers,
"hm_port": config.Y.HbaseMaster.Port,
})
})
// 根路由重定向到主页
route.GET("/", func(ctx *gin.Context) {
ctx.Redirect(http.StatusMovedPermanently, "/index")
})
// 服务的世界代理页
serviceGroup := route.Group("/service")
{
// serviceGroup.Use(AuthMiddler)
serviceGroup.GET("/nn/:host/:port/*path", GetNNWeb)
serviceGroup.GET("/rm/:host/:port/*path", GetRMWeb)
serviceGroup.GET("/hm/:host/:port/*path", GetHMWeb)
}
route.NoRoute(defaultFunc)
gin中有一个NoRoute方法,这个是用来处理找不到路由时的情况,防止出现找不到页面时没有平滑的处理,这里用一个默认处理函数对404进行处理,比如跳转到404页面。
反向代理
首先是反向代理,Go实现反向代理是比较简单的,这里由于需要区分路由进行代理转发所以就要做个处理,实现逻辑大概如下:
NewProxy
函数要求输入代理服务器targetHost
,路由路径path
,以及用于Kerberos认证时区分主机的sp
字符串,targetHost在传入后使用url.Parse
进行解析并作为变量传入NewSingleHostReverseProxy
方法就能生成一个反向代理结构体指针*httputil.ReverseProxy
了,这个时候所有的代理连接都会通过代理服务器做转发,路由路径会被拼接到端口之后:
如果我们只是代理单一的服务,可以这样去做,反正我们不用关心路由具体去哪,只要来我8088端口的流量我都转发到9444就行了,但是此处我们需要进行路由的处理,所以传入了path
变量,方便对路由的内容做处理;最后一个变量sp就是用于设置Spnego请求头的时候用的SPN,在Kerberos认证的时候,如果我请求的url对应的域名是host001的时候,我必须要指定SPN是属于host001的,具体的解释可以参照这段
因此,在每次创建代理的时候我都需要对SPN进行设置,这样才能保证每次我都能用正确的身份去请求对应的服务;
通过*httputil.ReverseProxy
的Director设置我可以对代理的请求进行处理,因此进行Kerberos认证的操作就在此处进行:
proxy.Director = func(req *http.Request) {
// 修改请求,此处尝试添加kerberos认证
originalDirector(req)
if ck != nil {
req.AddCookie(ck)
}
kc, err := config.Y.KerberosClient.CreatConfig()
if err != nil {
log.Error(err)
}
if err := spnego.SetSPNEGOHeader(kc, req, fmt.Sprintf("HTTP/%s", sp)); err != nil {
log.Error(err)
}
req.URL.Path = path
req.Host = url.Host
}
代理服务创建的完全代码如下,这其中还包括的TLS相关信息的配置,这里我按照自己需要进行了封装,最终返回一个*tls.Config
结构体指针用于创建http.Transport
即可
func NewProxy(targetHost string, path string, sp string) (*httputil.ReverseProxy, error) {
url, err := url.Parse(targetHost)
if err != nil {
return nil, err
}
proxy := httputil.NewSingleHostReverseProxy(url)
t := tls.New(config.Y.TLS.HTTPS, config.Y.TLSCa, config.Y.TLSCert, config.Y.TLSKey, config.Y.InsecureSkipVerify)
tlsConfig, err := t.TLSConfig()
if err != nil {
return nil, err
}
dialer := &net.Dialer{}
proxy.Transport = &http.Transport{
DialContext: dialer.DialContext,
DisableKeepAlives: true,
TLSClientConfig: tlsConfig,
}
// 此处是为了获取到原本的请求处理函数,不加这个的话,预制的处理逻辑会丢失
originalDirector := proxy.Director
proxy.Director = func(req *http.Request) {
originalDirector(req)
if ck != nil {
req.AddCookie(ck)
}
kc, err := config.Y.KerberosClient.CreatConfig()
if err != nil {
log.Error(err)
}
if err := spnego.SetSPNEGOHeader(kc, req, fmt.Sprintf("HTTP/%s", sp)); err != nil {
log.Error(err)
}
req.URL.Path = path
// 一定要将请求头的Host修改成代理的目标Host,否则Kerberos认证也不会通过
req.Host = url.Host
}
return proxy, nil
}
登录会话
因为我懒得去做啥用户系统,这本身也就是一个方便运维人员用的小程序,所以就简单依赖于Session来实现一个登录逻辑。
首先每个页面都需要对是否登录做一个验证,这就需要一个中间件,对于单一浏览器的session,直接使用github.com/gin-gonic/gin
包中的session进行设置,中间件会去检查Session中是否有名为Owl
的头信息,并且值是否为Login,如果是Login就直接认为登陆过了,否则重定向到登录页,中间件逻辑如下:
func AuthMiddler(c *gin.Context) {
session := sessions.Default(c)
if session.Get("Owl") != "Login" {
c.Redirect(http.StatusMovedPermanently, "/login")
return
}
}
Login服务的逻辑就是简单比对用户名和密码,因为懒得去做用户系统,这里就写成硬编码,用户名必须为admin,然后设置Session信息,这个信息会保存在请求头的Cookies中:
代码如下:
func Login(c *gin.Context) {
// 获取前端传来的登录用户信息
user := c.PostForm("username")
password := c.PostForm("password")
// 只做简单的比对并设置Session信息
if user == "admin" && password == "XXXX" {
session := sessions.Default(c)
session.Set("Owl", "Login")
session.Save()
c.Redirect(http.StatusFound, "/index")
} else {
c.Redirect(http.StatusMovedPermanently, "/login")
}
}
注销逻辑就是直接删除Owl信息即可:
func SignOut(c *gin.Context) {
session := sessions.Default(c)
session.Delete("Owl")
session.Save()
c.Redirect(http.StatusMovedPermanently, "/login")
}
组件代理
对于Namenode的Web代理比较简单,因为没有什么特殊的跳转,但是对于Yarn和Hbase有一定特殊性需要单独处理;
Yarn
Yarn的特殊性在于如果点入了备节点,会被默认重定向到主节点,然后代理可能就会404,这里要单独进行处理。
当发生重定向的时候,路由会变成/cluster
,并且http状态码会是307,所以这里可以在代理的响应处理中做处理,当状态码是307的时候,判断url中是否有cluster关键字,如果有就更改内存中保存的ActiveRm
变量的值为另一个节点的hostname,然后重定向到另一个节点的WebUi就可以了
proxy.ModifyResponse = func(r *http.Response) error {
url := r.Request.URL.Path
if r.StatusCode == 307 {
// 307的情况下是到了备的yarn节点
// 判断下是不是yarn
if strings.Contains(url, "cluster") {
service.GetActiveRm(r.Request.URL.Hostname())
}
c.Redirect(http.StatusMovedPermanently, fmt.Sprintf("/service/rm/%s/%v", service.ActiveRm, config.Y.ResourceManager.Port))
}
return nil
}
切换内存变量的方法逻辑如下:
func GetActiveRm(host string) {
if ActiveRm == "" {
ActiveRm = config.Y.ResourceManager.Servers[0]
} else {
for _, v := range config.Y.ResourceManager.Servers {
ActiveRm = v
break
}
}
}
Hbase
Hbase的WebUI的特殊性在于他的控制台除了主页以外,都是通过jsp模板渲染出来的:
我在本地测试的时候没有问题,页面可以正常访问,比如表信息查看的页面,正常来说是这样的:
一旦进行远程访问时,页面的Js文件和样式就加载不出来:
这个问题查了很久,后来对比了一下两个请求的信息,发现在Cookies中存在差别,请求头的Cookies应该包含hadoop-auth:
如果没有包含这个请求头,就会在进行Kerberos认证的时候使用同一个认证主体进行重复认证的问题,这样会发生报错:
Authentication exception: GSSException: Failure unspecified at GSS-API level (Mechanism level: Request is a replay (34))
我的处理方法是,设置一个全局变量和全局锁:
var (
yamlPath = flag.String("config.path", "./", "运行配置文件")
scheme = "http"
ck = &http.Cookie{}
lock = sync.Mutex{}
)
在进行访问的时候,检查Cookies中是否包含hadoop-auth,如果不包含就把请求中返回的hadoop-auth加入到Cookie中,为了防止出现冲突,使用全局锁进行控制:
proxy.ModifyResponse = func(r *http.Response) error {
cs := r.Cookies()
for _, v := range cs {
if v.Name == "hadoop.auth" {
lock.Lock()
ck = v
lock.Unlock()
}
}
return nil
}
这部分的处理逻辑只需要在设置代理的函数中进行添加即可。
结果
最终在各种修修补补下,完成了控制台程序的整体逻辑,并且能够正常使用了,前端使用BootStarp简单写了下,看着也算是有模有样了: