core 组件
此文件夹是 harbor 的核心组件。在根目录下有main.go
和router.go
二个文件。前者用来初始化整个后端项目,后者是对请求的路由处理。在分析一个操作的执行流程时,可以从路由文件入手,一步一步分析。
harbor 中的 controller设计,在common/api
文件夹中实现了一个BaseAPI
具体实现如下
type BaseAPI struct {
beego.Controller
}
复制代码
这个BaseAPI
实现了ControllerInterface
接口中的部分方法。在给 harbor 添加新的功能时,此功能对外暴露出来的 API
首先需要隐式的继承BaseController
结构体,在BaseController
结构体中继承了BaseAPI
。
以projectAPI
为例,它的具体实现如下
type ProjectAPI struct {
BaseController
project *models.Project
}
复制代码
上述 API 中继承的BaseController
实现如下,除了继承了 BaseAPI 之外,还实现了确保安全的上下文,以及项目管理器。
// BaseController ...
type BaseController struct {
api.BaseAPI
// SecurityCtx is the security context used to authN &authZ
SecurityCtx security.Context
// ProjectMgr is the project manager which abstracts the operations
// related to projects
ProjectMgr promgr.ProjectManager
}
复制代码
通过上述继承实现,用户在添加新的功能时,需要按照这样的实现逻辑添加新的控制器。
router.go
注册 harbor 支持的所有的路由,每个路由分为三个部分,具体实现如下所示:
beego.Router("/c/login", &controllers.CommonController{}, "post:Login")
复制代码
第一个参数是浏览器访问的地址,第二个参数是MVC 中的 C。Controller是用来处理不同URL的控制器,不同的路径有不同的控制器。在 harbor 中大致有一下二种控制器:
- controllers.CommonController
- api 中的不同操作的控制器(细分种类很多)
每个控制器都实现了一下的接口
type ControllerInterface interface {
Init(ct *context.Context, controllerName, actionName string, app interface{})
Prepare()
Get()
Post()
Delete()
Put()
Head()
Patch()
Options()
Finish()
Render() error
XSRFToken() string
CheckXSRFCookie() bool
HandlerFunc(fn string) bool
URLMapping()
}
复制代码
实现了上述接口定义的函数的结构体,通过路由根据url执行相应的controller的原则,会依次执行下列函数
Init() 初始化
Prepare() 执行之前的初始化,每个继承的子类可以来实现该函数
method() 根据不同的method执行不同的函数:GET、POST、PUT、HEAD等,子类来实现这些函数,如果没实现,那么默认都是403
Render() 可选,根据全局变量AutoRender来判断是否执行
Finish() 执行完之后执行的操作,每个继承的子类可以来实现该函数
复制代码
每个控制器的 Prepare 函数中都实现了认证功能,具体实现有差别,认证这一逻辑会具体介绍。
api
大部分功能的路由功能都在这里实现。
/api/users
post
添加新的用户到数据库中,用户的数据结构定义如下:
type User struct {
UserID int `orm:"pk;auto;column(user_id)" json:"user_id"`
Username string `orm:"column(username)" json:"username"`
Email string `orm:"column(email)" json:"email"`
Password string `orm:"column(password)" json:"password"`
Realname string `orm:"column(realname)" json:"realname"`
Comment string `orm:"column(comment)" json:"comment"`
Deleted bool `orm:"column(deleted)" json:"deleted"`
Rolename string `orm:"-" json:"role_name"`
// if this field is named as "RoleID", beego orm can not map role_id
// to it.
Role int `orm:"-" json:"role_id"`
// RoleList []Role `json:"role_list"`
HasAdminRole bool `orm:"column(sysadmin_flag)" json:"has_admin_role"`
ResetUUID string `orm:"column(reset_uuid)" json:"reset_uuid"`
Salt string `orm:"column(salt)" json:"-"`
CreationTime time.Time `orm:"column(creation_time);auto_now_add" json:"creation_time"`
UpdateTime time.Time `orm:"column(update_time);auto_now" json:"update_time"`
GroupList []*UserGroup `orm:"-" json:"-"`
}
复制代码
post
方法会对发送过来的请求进行一系列验证,只有当这些验证都通过时。前端界面上的注册按钮才生校,点击注册按钮后。表单中的信息会被发送到后端服务器上来,服务器在验证一遍用户的信息是否符合规范,当通过所有检查后,调用dao.registry(user)
进行注册,返回用户 ID。
/api/users/:user_id/password
更改密码服务 **Put()**方法,先检查验证模式是否允许更改密码。
controllers
此文件夹实现了用户登录,登出,检查用户或邮箱是否存储,重置邮箱,重置密码等功能。
proxy
拦截器:拦截器是指对浏览器到服务器的请求数据或者服务器到浏览器的返回数据做一些更改,或将请求的数据做一些增强。
定义了对于发送过来请求的处理链路,以及拦截器的实现。通过ServeHTTP
将实现的功能暴露出去。
这个拦截器会拦截pull manifest
请求,对这个请求进行改增。将 image 和tag/sha256 digest 作为返回值的第二和第三个参数。
interceptors.go
urlHandler结构体的 ServeHTTP
方法主要用来对原有的request
进行处理,使其附带上镜像信息
ServeHTTP
首先对请求进行正则处理,提取出flag, repository, reference
信息。使用repository
和tokenUsername
信息调用NewRepositoryClientForUI()
函数创建一个 repository client
仅用来访问内部的 registry
,这个client
增加了 "User-Agent" header
到请求中,同时加上token 服务所需要的信息。
readonlyHandler
结构体的 ServeHTTP
方法主要用来对request
进行限制,禁止请求的访问。
listReposHandler
结构体的 ServeHTTP
方法主要用来返回的请求上添加镜像仓库的信息。包含了仓库的内容及其长度信息,放在 Content-Length 字段中。
rw.Header().Set(http.CanonicalHeaderKey("Content-Length"), strconv.Itoa(clen))
rw.Write(respJSON)
复制代码
**contentTrustHandler
**结构体的 ServeHTTP
方法只有在Notary
服务开启时才生效
**vulnerableHandler
**结构体的 ServeHTTP
方法只有在clair
服务开启后才能生效。
最后二个等到需要实时再来仔细看。
auth
实现了用户的登录认证,在 harbor 中用户登录认证的方式分为二种:
- 数据库
- LDAP 方式
- uaa 认证
认证器的接口如下,用户可以很方便的实现自己的认证方式只要实现了下面接口提供的方法即可。
// AuthenticateHelper provides interface for user management in different auth modes.
type AuthenticateHelper interface {
// Authenticate authenticate the user based on data in m. Only when the error returned is an instance
// of ErrAuth, it will be considered a bad credentials, other errors will be treated as server side error.
Authenticate(m models.AuthModel) (*models.User, error)
// OnBoardUser will check if a user exists in user table, if not insert the user and
// put the id in the pointer of user model, if it does exist, fill in the user model based
// on the data record of the user
OnBoardUser(u *models.User) error
// Create a group in harbor DB, if altGroupName is not empty, take the altGroupName as groupName in harbor DB.
OnBoardGroup(g *models.UserGroup, altGroupName string) error
// Get user information from account repository
SearchUser(username string) (*models.User, error)
// Search a group based on specific authentication
SearchGroup(groupDN string) (*models.UserGroup, error)
// Update user information after authenticate, such as OnBoard or sync info etc
PostAuthenticate(u *models.User) error
}
复制代码
在 harbor 中可以给每个项目增加新的成员,新的组进行细粒度管理。其中的权限认证就是通过上述的几种方法实现的。(猜测)
CommonController
控制器的Login
方法就调用了auth
中登录的实现。根据登录模式的不同选择不同的验证方式,传入的参数为用户名和密码。
默认的验证方式为数据库验证,登录验证的逻辑如下:
- 检查认证方式
- 设置锁,避免短时间内有多次登录请求互相抢占
- 完成上述检查后,根据配置认证方式的不同调用对应的认证器
主要分析数据库认证
数据库认证实现的比较简单,直接调用dao
层中的LoginByDb
函数,以 orm 的方式直接构建 sql 语句查询是否有对应的用户在数据库中,查询成功后用户实例对象。数据库认证方式的PostAuthenticate
方法没有执行任何操作。没有问题后,认证完成。使用此用户信息创建 session,用户登录成功。
promgr
这里实现了对 harbor 中项目的管理。抽象出来一个项目管理的常规操作并将其定义为接口类型,ProjectManager
也实现了PMSDriver
接口的方法。所以当用户在增加新的ProjectManager
的时,只要实现了下面的方法也会实现PMSDriver
接口。
type ProjectManager interface {
Get(projectIDOrName interface{}) (*models.Project, error)
Create(*models.Project) (int64, error)
Delete(projectIDOrName interface{}) error
Update(projectIDOrName interface{}, project *models.Project) error
List(query *models.ProjectQueryParam) (*models.ProjectQueryResult, error)
IsPublic(projectIDOrName interface{}) (bool, error)
Exists(projectIDOrName interface{}) (bool, error)
// get all public project
GetPublic() ([]*models.Project, error)
// if the project manager uses a metadata manager, return it, otherwise return nil
GetMetadataManager() metamgr.ProjectMetadataManager
}
复制代码
PMSDriver的驱动共有 2 中分别为admiral
和local
,这二者的区别有待研究。
ProjectManager中存储的数据如下所示:
{
"project_id": 1,
"owner_id": 1,
"name": "library",
"creation_time": "2019-02-15T12:58:31.485307Z",
"update_time": "2019-02-15T12:58:31.485307Z",
"deleted": false,
"owner_name": "",
"togglable": true,
"current_user_role_id": 1,
"repo_count": 1,
"metadata": {
"auto_scan": "true",
"enable_content_trust": "false",
"prevent_vul": "false",
"public": "true",
"severity": "low"
}
},
复制代码
config
在 main.go
中调用了config.Init()
函数,因为配置文件的处理是在adminserver
组件中完成的,所以在 config.go
中通过创建一个adminserver
的client ,使用 http 通信的方式访问adminserver
的 URL 来获取配置文件的信息。
为了确保通信的安全,adminserver
的 client 在创建时会使用CORE_SECRET
参数进行加密。创建好的 client 会被赋值给一个Manager
结构体,这是用来管理配置文件的,具体结构如下:
// Manager manages configurations
type Manager struct {
client client.Client
Cache bool
cache cache.Cache
key string
}
复制代码
配置文件是直接存储在内存上。
if enableCache {
m.Cache = true
m.cache = cache.NewMemoryCache()
m.key = "cfg"
}
复制代码
在完成Manager
的创建之后,会调用 client 的GetCfgs
方法从adminserver
请求配置信息,并将配置信息存储在内存中。jobservice
与其他组件进行通信的密码。
完成上述的操作后,初始化ProjectManager
。当 harbor 开启admiral
是需要对 http 服务使用 x509 进行加密生成AdmiralClient
,在创建PSM驱动时需要将AdmiralClient
作为参数传入进行,在后续的通信过程中使用。同时海域要传入 token 服务的阅读器,具体实现如下。
driver = admiral.NewDriver(AdmiralClient, AdmiralEndpoint(), TokenReader)
复制代码
在没有开启admiral
的模式下,直接使用本地的数据库创建 项目管理器(ProjectManager)的驱动。最后使用上述的驱动创建一个全局项目管理器单例,用来对 harbor 的项目进行管理。
还有一些功能是 LDAP 的实现,这里不做介绍。
filter
用来对一些请求进行过滤,只有符合要求的请求才被允许访问 harbor。这里的过滤主要分为三种分别为:
- SecurityFilter
- ReadonlyFilter:在GC 的过程汇总开启
- MediaTypeFilter:只有这几种类型的请求才能访问
"application/json", "multipart/form-data", "application/octet-stream"
前二种过滤器对所有的请求都进行过滤,最后一种只对/api/*
类型的请求过滤。
SecurityFilter
遍历之前定义的四中修改器,对 req 对用每种修改器的Modify(req)
方法。
secretReqCtxModifier对发送的请求增加了二种信息:
- harbor_security_context:获取 req header中的
secret
,然后通过这个secret
和secret store 创建NewSecurityContext - harbor_project_manager:
basicAuthReqCtxModifier 获取请求中的用户名和密码,然后使用账户信息进行登录验证,如果请求中发来的用户信息和数据库中一致。就可以对请求进行修改,给请求增加用户和 pm 信息。具体如下
securCtx := local.NewSecurityContext(user, pm)
setSecurCtxAndPM(ctx.Request, securCtx, pm)
复制代码
sessionReqCtxModifier
对前端发送来的请求进行修改,通过req
中的session
信息得到用户的详细信息。然后将用户的信息和GlobalProjectMgr
信息存储到req
的 context 中,具体实现如下
securCtx := local.NewSecurityContext(&user, pm)
setSecurCtxAndPM(ctx.Request, securCtx, pm)
复制代码
unauthorizedReqCtxModifier
对于没有授权的请求,用户信息为 nil,在修改req
时没有用户信息有pm
securCtx = local.NewSecurityContext(nil, pm)
setSecurCtxAndPM(ctx.Request, securCtx, pm)
复制代码
ReadonlyFilter
开启 readonly 过滤后,当请求中有删除操作或重新打 label 操作时,请求都会被禁止。
GetSecurityContext
此函数在 BaseController
的Prepare
函数中被调用,用来获取请求中的security context
并返回。在发送到后端的数据,都是通过security context
来进行传递的,前端的数据通过上述过滤器被修改为security context
。
根据请求中的关键字SecurCtxKey=harbor_security_context
来获取security context
。
GetProjectManager
获取请求中的project manager
。根据请求中的关键字PmKey=harbor_project_manager
来获取。
日志的实现
- 如何实现让日志服务成为单独一个容器,记录来自不同的容器的日志信息
主要需要考虑的就是在每个记录日志的地方,留意日志的输出是设置到哪里的。
除了 jobservice文件夹,其他的组件使用的都是src/common/utils/log
提供的日志服务,比较的轻量级。
context 设计
在common
包中定义了context
接口,抽象出操作的通用方法。判断前端发来的请求中,用户的身份信息是否有相对应的权限,都是在这个接口中定义的。
// Context abstracts the operations related with authN and authZ
type Context interface {
// IsAuthenticated returns whether the context has been authenticated or not
IsAuthenticated() bool
// GetUsername returns the username of user related to the context
GetUsername() string
// IsSysAdmin returns whether the user is system admin
IsSysAdmin() bool
// IsSolutionUser returns whether the user is solution user
IsSolutionUser() bool
// HasReadPerm returns whether the user has read permission to the project
HasReadPerm(projectIDOrName interface{}) bool
// HasWritePerm returns whether the user has write permission to the project
HasWritePerm(projectIDOrName interface{}) bool
// HasAllPerm returns whether the user has all permissions to the project
HasAllPerm(projectIDOrName interface{}) bool
// Get current user's all project
GetMyProjects() ([]*models.Project, error)
// Get user's role in provided project
GetProjectRoles(projectIDOrName interface{}) []int
}
复制代码
上述定义的context
接口具体有三种实现分别为:admiral,local,secret。
重点分析 local 也就是数据库模式的实现。在local 模式中,定义了一个信息的结构体用来管理用户和项目。通过这个结构体将用户和项目关联在一个。
// SecurityContext implements security.Context interface based on database
type SecurityContext struct {
user *models.User
pm promgr.ProjectManager
}
复制代码
IsAuthenticated
真正的验证过程是在core/filter
中的security.go
实现的,IsAuthenticated
函数只是调用之前处理好的内容。在security.go
中已经获取到来用户和 pm 信息,验证是只需检查用户是否为空即可。
func (s *SecurityContext) IsAuthenticated() bool {
return s.user != nil
}
复制代码
GetProjectRoles
用来查找摸个项目用多少用户。先根据请求提供的用户名在数据库中查找多对应的用户信息,查找的 sql 语句如下:
sql := `select user_id, username, password, email, realname, comment, reset_uuid, salt,
sysadmin_flag, creation_time, update_time
from harbor_user u
where deleted = false and user_id = ? and username = ? and reset_uuid = ? and email = ?`
复制代码
查询到用户数据之后,开始查询项目,传入的参数projectIDOrName
。SecurityContext
调用ProjectManager
的Get
方法来根据projectIDOrName
参数查询项目。这个 Get方式根据pmsDriver
驱动的不同调用不同的实现,因为这里pmsDriver
驱动模式是local
,会直接在数据中进行查询,具体的项目查询实现如下:
// Get ...
func (d *driver) Get(projectIDOrName interface{}) (
*models.Project, error) {
id, name, err := utils.ParseProjectIDOrName(projectIDOrName)
if err != nil {
return nil, err
}
if id > 0 {
return dao.GetProjectByID(id)
}
return dao.GetProjectByName(name)
}
复制代码
最后在根据上述查询到的用户名和项目名来查询此项目有多少用户。
最后返回给前端的 json 格式数据如下:
[
{
"id": 1,
"project_id": 1,
"entity_name": "admin",
"role_name": "projectAdmin",
"role_id": 1,
"entity_id": 1,
"entity_type": "u"
},
{
"id": 2,
"project_id": 1,
"entity_name": "chenxu",
"role_name": "developer",
"role_id": 2,
"entity_id": 3,
"entity_type": "u"
}
]
复制代码
展现出来的效果如下:
GetMyProjects
查询实现如下,具体来说是调用了ProjectManager
的List
方法,根据PSM
实现的具体驱动调用对应的方法。这里驱动是local
,获取总的镜像数量和每个镜像的详细信息。
result, err := s.pm.List(
&models.ProjectQueryParam{
Member: &models.MemberQuery{
Name: s.GetUsername(),
GroupList: s.user.GroupList,
},
})
复制代码
前端接收到的json 数据格式如下:
[
{
"id": 1,
"name": "library/centos",
"project_id": 1,
"description": "",
"pull_count": 0,
"star_count": 0,
"tags_count": 1,
"labels": [],
"creation_time": "2019-05-10T04:21:34.499267Z",
"update_time": "2019-05-10T04:21:34.499267Z"
},
{
"id": 2,
"name": "library/ubuntu",
"project_id": 1,
"description": "",
"pull_count": 0,
"star_count": 0,
"tags_count": 1,
"labels": [],
"creation_time": "2019-05-10T04:22:17.538373Z",
"update_time": "2019-05-10T04:22:17.538373Z"
}
]
复制代码
展示界面如下
GetRolesByGroup
按组来查询用户,组的概念只有在 LADP 模式下才启用。
token 服务
为registry
和notary
组件服务。这些组件需要 token 的验证服务才能正确的运行。
当用户pull/push
镜像时都需要使用到 token 服务,token 服务其作用的基本流程如下:
下面具体讲解一下 harbor 中 token 服务是如何实现。当 harbor 的后端收到了GET "/service/token"
路由请求时,会调用token.go
中的 Get()方法。在 Get方法中主要完成一下几件事:
- 获取请求中的 service即资源托管的服务地址
service="registry.docker.io"
- 根据Authorization Service 创建 token
- 将获取的 token 以 json 格式发送回去
具体解释一下Authorization Service 是如何创建token 的。
生成 token
在func (g generalCreator) Create(r *http.Request) (*models.Token, error)
方法中主要的执行逻辑如下:
- 获取请求中的 socpes。
scope="repository:samalba/my-app:pull,push"
- 获取请求中的
securitycontext
和ProjectManager
- 根据
socpes
获取用户对镜像仓库的资源有何种访问权限:pull or push - 迭代资源操作列表(检查ProjectManager是否有对应的 project 同时判断用户对其有何种权限),并尝试使用与资源类型匹配的筛选器来筛选操作。执行的结果信息都保存在 ctx 中
- 使用用户名,service,access 来生成 token 并返回。
这是生成 token的函数MakeToken(username, service string, access []*token.ResourceActions) (*models.Token, error)
。生成 token 的核心函数如下:文件在src/core/service/token/authutils.go#143
func makeTokenCore(issuer, subject, audience string, expiration int,
access []*token.ResourceActions, signingKey libtrust.PrivateKey) (t *token.Token, expiresIn int, issuedAt *time.Time, err error)
复制代码
从传入的数据中构造出镜像的基本信息:命名空间,镜像名,标签。具体数据结构如下:
type image struct {
namespace string
repo string
tag string
}
复制代码
这里有二个过滤器分别为registryFilter
和repositoryFilter
。后者用来判断用户对于某个具体的项目拥有什么样的权限,rwm
orRW
orR
。
通知服务
主要用来获取扫描策略和复制策略的变化情况。每一个订阅事件都需要传入二个参数来初始化通知功能,这二个参数分别为topic
和handler
。topic
是需要通知事件的主题,handler
是事件的处理方法。
其中handler
定义为以下接口类型,便于用户的扩充。
type NotificationHandler interface {
// Handle the event when it coming.
// value might be optional, it depends on usages.
Handle(value interface{}) error
// IsStateful returns whether the handler is stateful or not.
// If handler is stateful, it will not be triggerred in parallel.
// Otherwise, the handler will be triggered concurrently if more
// than one same handler are matched the topics.
IsStateful() bool
}
复制代码
漏洞扫描
接下来主要分析一下漏洞扫描服务的通知机制。镜像扫描的topic
是scan_all_policy
,handler
是ScanPolicyNotificationHandler
其用来处理扫描策略的改变。这里策略有二种每天和无,每天选项可以设置一个具体的时间。
前端设置为每日上午 10 点进行扫描,发送给后端的数据 json格式为:
"scan_all_policy": {
"value": {
"parameter": {
"daily_time": 7200
},
"type": "daily"
},
"editable": true
},
复制代码
在后端中会根据scan_all_policy
的 type
字段执行对应的处理逻辑。首先取消之前的所有任务,避免阻塞。然后解析传入的时间构造调度cron
,根据cron
创建调度任务。
// 先取消所有扫描任务
if err := cancelScanAllJobs(); err != nil {
return fmt.Errorf("Failed to cancel scan_all jobs, error: %v", err)
}
// 解析前端传入的时间
h, m, s := common_utils.ParseOfftime(notification.DailyTime)
cron := fmt.Sprintf("%d %d %d * * *", s, m, h)
// 创建定时的调度任务
if err := utils.ScheduleScanAllImages(cron); err != nil {
return fmt.Errorf("Failed to schedule scan_all job, error: %v", err)
}
复制代码
在ScheduleScanAllImages
中会任结构体,然后将任务通过 client 发送给jobservice
组件。提交任务调用的是jobservice
的/api/v1/jobs
接口,jobservice
完成对应的处理后,返回任务的状态码。
/service/notifications/ 路由
adminjob
监听来自jobservice
的/service/notifications/jobs/adminjob/*
路由请求。为 job 在数据库和jobservice
之间建立映射关系。job 在数据库中以jobid
的形式,在jobservice
中以UUID
的方式进行标识。
clair
用来监听 clair 的通知请求,监听的路由地址为/service/notifications/clair/
。用来从 clair 获取漏洞数据并返回给前端展示。
jobs
用来处理 jobservice 的请求,监听的路由地址为/service/notifications/jobs/*
。主要是用来更新扫描漏洞 job 的状态和更新镜像复制 job 的状态。
registry
监听 registry 的时间,监听路由为/service/notifications/
。实现了对docker push 和 pull 。其中的Post
函数用来实现所有功能。
当监听到新的/service/notifications/
请求时,Post
会首先提取出请求中Notification
这是一个事件的包装器结构如下:
// Notification holds all events.
type Notification struct {
Events []Event
}
复制代码
然后对传来的事件进行过滤,目前只支持二种事件。分别为:
- 来自外部的 docker-client 的 pull or push 请求
- 来自 jobservice 的 push 请求
完成事件过滤后,开始对事件进行加工提取,获取以下数据并持久化存储在数据库中:
dao.AddAccessLog(models.AccessLog{
Username: user,
ProjectID: pro.ProjectID,
RepoName: repository,
RepoTag: tag,
Operation: action,
OpTime: time.Now(),
}
复制代码
接来是对具体的push
或pull
操作的实现。
push
当时push
操作时,会首先检查数据库中是否有对应的repository
。如果没有将操作的 push记录存储在数据库中,等待镜像的 manifest 文件准备好。
等上述准备工作都完成之后,事件通知器发布事件。具体实现如下:
err := notifier.Publish(topic.ReplicationEventTopicOnPush, rep_notification.OnPushNotification{
Image: image,
})
复制代码
上述操作会调用对应的处理器对镜像执行操作。可能是处理用户从上传来的镜像,也可能是镜像仓库之间的复制操作,具体要看调用的什么。当有镜像上传到存储库之后会检查是否开启了镜像自动扫描功能。
pull repository 中的pull计数器加 1。
有疑问加站长微信联系(非本文作者)