从前有个故事, 说一家工厂的电机坏了, 谁都修不好, 找了一个老工程师, 老工程师绕着电机敲敲打打转了几圈, 然后在一个位置用粉笔画了个圈, 说把这个拆下来换掉就好了. 然后开价一万刀. 老板嫌贵, 问你就画个圈, 凭什么要一万美元, 老工程师说, 一个粉笔圈值 一美元, 知道在哪画圈值 九千九百九十九美元.


很久没有写博客了, 这两年搞hadoop集群搞的少了, 总觉得没啥可写的. 最近因为业务需要, 在k8s和jupyter上面做了不少二次开发, 除了之前写的乱七八糟记录, 打算把一些阅读源码的经验和二次开发的代码记录一下. 内容可能包括之前写的乱七八糟记录的内容, 整理一下, 写个系列.


这篇先整理记录一下之前的kerberos整合.


本身jupyterhub似乎提供krb的验证方法, 但是不太合适给我们用, 甲方最开始是需要接入他们的sso, 然后后来又改成了直接用linux user验证. 总之无论何种方法, 我们给甲方提供的集群里, 并没有做linux用户和LDAP和kerberos的整合, 所以我也没细看jupyter提供的krb验证插件, 直接自己写了一个.

无论是走sso, 还是linux user, 用户名是必不可少的, 我需要拿到用户名去做krb验证. (linux用户和krb用户并没有强关联性, krb用户可以不一定是linux用户, 只要kdc数据库里面有用户名就可以.)


既然涉及验证, 那么首先需要修改的就是login.


jupyter生态后端全部使用tornado开发, 前端因生态不同有些差异.


那么在jupyterhub里面, 管验证的就是在 jupyterhub/handlers/login.py

(括号说明一下,  jupyterhub页面上显示登录框和做form提交动作的是login.py, 实际做用户密*码校验的是单独一个python类, 默认是使用PAM验证, 也就是linux用户密*码方式, 先回去说login)


在login.py里面我把之前jupyterhub的代码注释掉了, 然后单写了一段代码去请求甲方的sso, 拿到用户名以后交给jupyterhub设置的authenticator去做校验.

class LoginHandler(BaseHandler):
"""Render the login page."""

# commented for matrix ai
args = dict()
args['contenttype'] = 'application/json'
args['app'] = 'dmp-jupyterhub'
args['subkey'] = 'xxxxxxxxxxxxxxxxxxx'

def _render(self, login_error=None, username=None):
return self.render_template(
'login.html',
next=url_escape(self.get_argument('next', default='')),
username=username,
login_error=login_error,
custom_html=self.authenticator.custom_html,
login_url=self.settings['login_url'],
authenticator_login_url=url_concat(
self.authenticator.login_url(self.hub.base_url),
{'next': self.get_argument('next', '')},
),
)

async def get(self):
"""
modify to fit pg's customized oauth2 system, if this method publish with matrix ai, then comment all,
and write code that only get username from matrixai login form.
"""
self.statsd.incr('login.request')
user = self.current_user

if user:
# set new login cookie
# because single-user cookie may have been cleared or incorrect
# 如果存在用户, 设置登录cookie并直接跳转下一个url
self.set_login_cookie(user)
self.redirect(self.get_next_url(user), permanent=False)
else:
# 如果不存在用户则进行登录操作
'''
# 以下都是原jupyterhub login内容,已注释掉
if self.authenticator.auto_login:
auto_login_url = self.authenticator.login_url(self.hub.base_url)
if auto_login_url == self.settings['login_url']:
# auto_login without a custom login handler
# means that auth info is already in the request
# (e.g. REMOTE_USER header)
user = await self.login_user()
if user is None:
# auto_login failed, just 403
raise web.HTTPError(403)
else:
self.redirect(self.get_next_url(user))
else:
if self.get_argument('next', default=False):
auto_login_url = url_concat(
auto_login_url, {'next': self.get_next_url()}
)
self.redirect(auto_login_url)
return
username = self.get_argument('username', default='')
self.finish(self._render(username=username))
'''
# 以下是二次开发的login get
import json
import requests
access_token = self.get_cookie('access_token') # 这个是系统需要的cookie, 由别的地方传递过来
self.log.info("access_token: " + access_token)
token_type = self.get_argument('token_type', 'Bearer')
if access_token != '':
# 拿着传递的token请求对方的 sso 地址, 获取ShortName, ShortName 是后面做krb验证所需要的用户名
userinfo_url = 'https://xxxx.com/paas-ssofed/v3/token/userinfo'
headers = {
'Content-Type': 'application/x-www-form-urlencoded',
'Ocp-Apim-Subscription-Key': self.args['subkey'],
'Auth-Type': 'ssofed',
'Authorization': token_type + ' ' + access_token
}
body = {
'token': access_token
}
resp = json.loads(requests.post(userinfo_url, headers=headers, data=body).text)
user = resp['ShortName']
data = dict()
data['username'] = user
data['password'] = ''
# 将ShortName放入 字典, 去调用 hub 自己的 login_user方法, 该方法会使用外部的用户验证类去做校验, 这个字典和字段名是固定写法, 不可改变.
user = await self.login_user(data)
self.set_cookie('username', resp['ShortName'])
self.set_login_cookie(user)
self.redirect(self.get_next_url(user))
else:
self.redirect('http://xxxx.cn:3000/')


LoginHandler的post方法不需要改.


然后是需要自己写一个authenticator的类, 这个类其实没啥可做的, 因为对方是sso验证, 只要返回了ShortName, 说明就是验证ok的, 验证器直接透明返回真就可以.


比如, 自己在 jupyterhub里面新建一个文件夹叫 garbage_customer, 新建一个文件叫 gcauthenticator.py, 内容如下:

#!/usr/bin/env python

from tornado import gen
from jupyterhub.auth import Authenticator

# GC验证类扩展自jupyterhub的auth Authenticator类, 此GC非彼GC, 为保护客户隐私, 原代码中的甲方公司缩写, 用 garbage_customer和 GC 代替
class GCAuthenticator(Authenticator):
"""
usage:
1. generate hub config file with command
jupyterhub --generate-config /path/to/store/jupyterhub_config.py

2. edit config file, comment
# c.JupyterHub.authenticator_class = 'jupyterhub.auth.PAMAuthenticator'
and write a new line
c.JupyterHub.authenticator_class = 'jupyterhub.garbage_customer.gcauthenticator.GCAuthenticator
"""

@gen.coroutine
# 入口参数固定写法, 啥也不做, 直接返回用户名, 即为真.这里传递的data, 就是之前在login里面定义的data['username']和data['password']
# 但是由于验证已经由甲方的sso做了, 所以我们用不到password, 但是格式还是要遵守的.
# 其实按照这个思路, 自己把这个方法改写成mysql, postgres, 或者文本文件做用户验证, 其实也很简单.
def authenticate(self, handler, data):
user = data['username']
return user


然后jupyterhub安装就不讲了, 安装完需要创建配置文件, 我的注释里有写, 创建之后, 需要编辑配置文件, 将里面的PAMAuthenticator替换为新写的啥也不干的GCAuthenticator即可.


到这里, 自定义的验证登录就完成了, 这里是记录一下jupyterhub的验证登录二次开发的方式, 其实也没那么复杂, 主要就是知道jupyterhub里面内部传递的数据格式就很好办了.


接下来, 是在本地服务做kerberos验证, kerberos怎么搭建配置就不提了, 假设krb环境都是ok的.

Jupyter生态二次开发系列(一)_二次开发

借用自己之前文章里的图片, login完成之后, jupyterhub会调用singleuserapp去启动一个子进程, 这个子进程的作用是给每个登录的用户做notebook管理, 在jupyterhub中, 每个notebook实际上都是 singleuserapp的子进程, 也就是jupyterhub的孙进程. 而kernel是notebook的子进程, 即jupyterhub的曾孙进程, 这个不在这篇里面详细讲了就.


在linux里面, 每一个子进程你可以认为都是一个独立的终端, 有自己的 sys env环境. 所以, 做krb验证, 在singleuserapp里面就可以. 但是, 我们不需要修改 jupyterhub/singleuser.py这个文件, 不需要. jupyterhub提供了调用自定义创建子进程的方法. 


login完成之后, jupyterhub会调用 spawner方法去创建子进程, spawner方法会调用singleuserapp去创建notebook的子进程,  在系统进程里显示是 singleruser, 但实际上, 该子进程是由spawner创建的, 所以, 我们只需要创建spawner的扩展方法就可以完成krb认证并加入环境变量的过程. 这个过程不需要改写spawner.py, 只需要创建新的方法就可以.


jupyterhub的子进程 顺序是 hub_login -> spawner (singleuserapp (notebook) ) -> kernel, 当用户在hub里面再启动一个notebook的时候, 又是一个新的spawner的singeruserapp 


还是在garbage_customer文件夹里面创建一个新文件, 叫gckrbspawner.py

# Save this file in your site-packages directory as krbspawner.py
#
# then in /etc/jupyterhub/config.py, set:
#
# c.JupyterHub.spawner_class = 'garbage_customer.gckrbspawner.KerberosSpawner'


from jupyterhub.spawner import LocalProcessSpawner
from jupyterhub.utils import random_port
from subprocess import Popen,PIPE
from tornado import gen
import pipes

REALM = 'GC.COM'

# KerberosSpawner扩展自spawner.py的localProcessSpawner类
class KerberosSpawner(LocalProcessSpawner):
@gen.coroutine
def start(self):
"""启动子进程的方法"""
if self.ip:
self.user.server.ip = self.ip # 用户服务的ip为jupyterhub启动设置的ip
else:
self.user.server.ip = '127.0.0.1' # 或者是 127.0.0.1
self.user.server.port = random_port() # singleruser server, 也就是notebook子进程, 启动时使用随机端口
self.log.info('Spawner ip: %s' % self.user.server.ip)
self.log.info('Spawner port: %s' % self.user.server.port)
cmd = []
env = self.get_env() # 获取jupyterhub的环境变量
# self.log.info(env)

""" Get user uid and gid from linux"""
uid_args = ['id', '-u', self.user.name] # 获取当前登录的用户名对应的linux uid
uid = Popen(uid_args, stdin=PIPE, stdout=PIPE, stderr=PIPE)
uid = uid.communicate()[0].decode().strip()
gid_args = ['id', '-g', self.user.name] # 获取当前登录用户对应的 linux gid
gid = Popen(gid_args, stdin=PIPE, stdout=PIPE, stderr=PIPE)
gid = gid.communicate()[0].decode().strip()
self.log.info('UID: ' + uid + ' GID: ' + gid)
self.log.info('Authenticating: ' + self.user.name)

cmd.extend(self.cmd)
cmd.extend(self.get_args())

self.log.info("Spawning %s", ' '.join(pipes.quote(s) for s in cmd))
# 使用linux用户认证kerberos用户, 由于 jupyterhub默认使用 /home/username作为每个用户的文件夹, 所以我把用户认证需要的keytab放到每个/home/username下面
# 例如 xianglei.wb1.keytab ,对应的linux用户就是xianglei, 对应的krb用户就是 xianglei/gc-dmp-workbench1@GC.COM.
kinit = ['/usr/bin/kinit', '-kt',
'/home/%s/%s.wb1.keytab' % (self.user.name, self.user.name,),
'-c', '/tmp/krb5cc_%s' % (uid,),
'%s/gc-dmp-workbench1@%s' % (self.user.name, REALM)]
self.log.info("KRB5 initializing with command %s", ' '.join(kinit))
# 使用subprocess的Popen在spawner里面创建子进程去做krb认证
Popen(kinit, preexec_fn=self.make_preexec_fn(self.user.name)).wait()

popen_kwargs = dict(
preexec_fn=self.make_preexec_fn(self.user.name),
start_new_session=True, # 不转发 signals
)
popen_kwargs.update(self.popen_kwargs)
popen_kwargs['env'] = env
self.proc = Popen(cmd, **popen_kwargs)
self.pid = self.proc.pid
# 返回ip和端口号, 交还给jupyterhub server进行子进程服务注册
return (self.user.server.ip, self.user.server.port)


然后在jupyterhub生成的config文件里面, 加一条

c.JupyterHub.spawner_class = 'garbage_customer.gckrbspawner.KerberosSpawner'

就可以了.


到这里, jupyterhub自定义开发验证类, 以及自定义的krb验证就都还没完事.


由于krb认证是有时效性的, 而我们的 garbage_customer并不想自己去手工完成krb刷新的工作, 所以, 还需要一步就是自动完成krb认证刷新. 这个不在jupyterhub里面完成.

思路是由于用户会常开notebook, 所以刷新的工作由notebook完成即可. notebook里面会继承来自jupyterhub spawner传递过来的用户环境变量, 用户名等信息, 所以, ok了.


打开 notebook/handlers.py

修改代码

class NotebookHandler(IPythonHandler):

@web.authenticated
@gen.coroutine
def get(self, path):
"""get renders the notebook template if a name is given, or
redirects to the '/files/' handler if the name is not given."""
path = path.strip('/')
cm = self.contents_manager

# will raise 404 on not found
try:
model = yield maybe_future(cm.get(path, content=False))
except web.HTTPError as e:
if e.status_code == 404 and 'files' in path.split('/'):
# 404, but '/files/' in URL, let FilesRedirect take care of it
return FilesRedirectHandler.redirect_to_files(self, path)
else:
raise
if model['type'] != 'notebook':
# not a notebook, redirect to files
return FilesRedirectHandler.redirect_to_files(self, path)
name = path.rsplit('/', 1)[-1]
username = self.current_user['name']
self.kinit(username)
self.write(self.render_template('notebook.html',
notebook_path=path,
notebook_name=name,
kill_kernel=False,
mathjax_url=self.mathjax_url,
mathjax_config=self.mathjax_config,
get_frontend_exporters=get_frontend_exporters
)
)

# 这里是krb认证, 即打开notebook时就做一次krb认证
def kinit(self, username):
import os
import subprocess
uid_args = ['id', '-u', username]
uid = subprocess.Popen(uid_args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
uid = uid.communicate()[0].decode().strip()
gid_args = ['id', '-g', username]
gid = subprocess.Popen(gid_args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
gid = gid.communicate()[0].decode().strip()
self.log.info('UID: ' + uid + ' GID: ' + gid)
self.log.info('Authenticating: ' + username)
realm = 'GC.COM'
kinit = '/usr/bin/kinit'
krb5cc = '/tmp/krb5cc_%s' % (uid,)
keytab = '/home/%s/%s.py1.keytab' % (username, username,)
principal = '%s/gc-dmp-python1@%s' % (username, realm,)
kinit_args = [kinit, '-kt', keytab, '-c', krb5cc, principal]
self.log.info('Running: ' + ' '.join(kinit_args))
kinit = subprocess.Popen(kinit_args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
self.log.info(kinit.communicate())
ans = None
# 将krb认证缓存文件置为该进程用户的 0600权限
if os.path.isfile(krb5cc):
os.chmod(krb5cc, 0o600)
os.chown(krb5cc, int(uid), int(gid))
ans = username
return ans


然后打开 services/contents/handlers.py

这个文件是notebook做自动保存时调用的api接口, 既然notebook带有自动保存功能, 我们就用该自动保存功能完成自动krb刷新.

首先加入同上的代码

class ContentsHandler(APIHandler):
def kinit(self, username):
import os
import subprocess
uid_args = ['id', '-u', username]
uid = subprocess.Popen(uid_args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
uid = uid.communicate()[0].decode().strip()
gid_args = ['id', '-g', username]
gid = subprocess.Popen(gid_args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
gid = gid.communicate()[0].decode().strip()
self.log.info('UID: ' + uid + ' GID: ' + gid)
self.log.info('Authenticating: ' + username)
realm = 'GC.COM'
kinit = '/usr/bin/kinit'
krb5cc = '/tmp/krb5cc_%s' % (uid,)
keytab = '/home/%s/%s.py1.keytab' % (username, username,)
principal = '%s/gc-dmp-python1@%s' % (username, realm,)
kinit_args = [kinit, '-kt', keytab, '-c', krb5cc, principal]
self.log.info('Running: ' + ' '.join(kinit_args))
kinit = subprocess.Popen(kinit_args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
self.log.info(kinit.communicate())
ans = None
if os.path.isfile(krb5cc):
os.chmod(krb5cc, 0o600)
os.chown(krb5cc, int(uid), int(gid))
ans = username
return ans

# 修改 _save方法, 在保存时加入self.kinit方法
@gen.coroutine
def _save(self, model, path):
"""Save an existing file."""
chunk = model.get("chunk", None)
if not chunk or chunk == -1: # Avoid tedious log information
self.log.info(u"Saving file at %s", path)
if 'name' in self.current_user:
if isinstance(self.current_user['name'], str):
# 这里加入kinit, 那么无论用户是手动保存, 还是notebook自动保存, 都会重新做一次kinit认证,刷新principal的有效期.
self.kinit(self.current_user['name'])
model = yield maybe_future(self.contents_manager.save(model, path))
validate_model(model, expect_content=False)
self._finish_model(model)


到这里, 算是彻底完成了hub认证二次开发和kerberos认证的工作. 尊贵的甲方数据科学家可以愉快的用他们公司的sso登录我们提供的jupyterhub服务做海量数据的笛卡尔积开发了.


我就是故事开头的老工程师.