GitLab 使用 Server Hooks 校验 Commit 用户名与邮箱
March 2, 2021 默认分类
校验原因
在企业内部进行代码提交时, commit 中会存在提交者的 username 与 email
但该 username 与 email 是提交者在 Git 客户端自己设置的
如果提交者忘记设置或者设置错误, 并将 commit push 到远程服务后
当协作者需要寻找该 commit 提交者时, 错误的 username 与 email 会对协作者造成障碍
为解决这个问题, 需要在 GitLab 使用 Server Hooks 对 commit 进行校验, 只有 username 与 email 与 GitLab 中的一致才允许 push, 否则拒绝 push 并提示修改
相关文章资料
最开始用 Google 搜索到的方案是使用 GitLab 的 Push Rules 功能,具体文档见 这里,看完了我才发现这是企业版独有的,作为比较有逼格(qiong)的我们是不可能接受这种 “没技术含量” 的方式的;后来找了好多资料,发现还得借助 Git Hook 功能,文档见 Custom Git Hooks;简单地说 Git Hook 就是在 git 操作的不同阶段执行的预定义脚本,GitLab 目前支持 pre-receive
update
post-receive
这几个钩子,当然他可以链式调用;所以一切操作就得从这里入手
准备工作
如需对 commit 的 username 与 email 进行校验, 那么需要在校验脚本中获取 push 者的 username 与 email
通过 GitLab Server Hooks 文档可知存在 GL_USERNAME 环境变量, 该变量的值为 push 者的 GitLab 的 username, 但是缺乏 email 相关环境变量
为获取 push 者的 email, 需使用 GitLab 提供的 Users API 进行获取
通过 API 文档可知只有 admin 用户才返回用户 email, 所以需要先使用 admin 账号生成一个 TOKEN
这个 TOKEN 只是用来获取获取用户 email, 故创建时选择 read_user 的范围即可
校验用户名与邮箱 pre-receive 实现
GitHub 的 platform-samples 项目提供了一个 commit-current-user-check.sh 的 hook, 我们可以参考该脚本. 查阅gitlab了相关资料得出,在进行 push 时,GitLab 会调用这个钩子文件,
单独项目配置:
这个钩子文件必须放在 /var/opt/gitlab/git-data/repositories/<group>/<project>.git/custom_hooks
目录中,当然具体路径也可能是 /home/git/repositories/<group>/<project>.git/custom_hooks
;custom_hooks
目录需要自己创建,具体可以参阅文档的 Setup;
全局项目配置: (参考)
对于从源安装的默认目录通常是
/home/git/gitlab-shell/hooks
。在此位置创建一个新目录。
对于Omnibus GitLab
(docker 部署的gitlab也是在此路径),通常是安装/opt/gitlab/embedded/service/gitlab-shell/hooks
取决于钩的类型,它可以是一个 pre-receive.d
, post-receive.d
或update.d
目录, hooks
有可能需要自己创建. 具体路径示例/opt/gitlab/embedded/service/gitlab-shell/hooks/pre-receive.d/
(本人基于docker跑的gitlab....懒)
在进行 push 操作时,GitLab 会调用这个钩子文件,并且从 stdin 输入三个参数,分别为 之前的版本 commit ID、push 的版本 commit ID 和 push 的分支;根据 commit ID 我们就可以很轻松的获取到提交信息,从而实现进一步检测动作;根据 GitLab 的文档说明,当这个 hook 执行后以非 0 状态退出则认为执行失败,从而拒绝 push;同时会将 stderr 信息返回给 client 端;说了这么多,下面就可以直接上代码了,为了方便我就直接用 go 造了一个 pre-receive,官方文档说明了不限制语言. 完整代码
package main
import (
"encoding/json"
"fmt"
"io/ioutil"
"log"
"log/syslog"
"net/http"
"os"
"os/exec"
"regexp"
"strconv"
"strings"
)
// 请求gitlab api返回用户信息json结构体
type Commits struct {
Commit string `json:"commit,omitempty"`
AbbreviatedCommit string `json:"abbreviated_commit,omitempty"`
Subject string `json:"subject,omitempty"`
SanitizedSubjectLine string `json:"sanitized_subject_line,omitempty"`
Author User `json:"author,omitempty"`
Committer User `json:"committer,omitempty"`
}
type User struct {
Name string `json:"name,omitempty"`
Email string `json:"email,omitempty"`
Date string `json:"date,omitempty"`
Timestamp string `json:"timestamp,omitempty"`
RelativeDate string `json:"relative_date,omitempty"`
}
type GitlabUserInfo struct {
ID int `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Username string `json:"username,omitempty"`
State string `json:"state,omitempty"`
AvatarURL string `json:"avatar_url,omitempty"`
WebURL string `json:"web_url,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
Bio string `json:"bio,omitempty"`
BioHTML string `json:"bio_html,omitempty"`
Location interface{} `json:"location,omitempty"`
PublicEmail string `json:"public_email,omitempty"`
Skype string `json:"skype,omitempty"`
Linkedin string `json:"linkedin,omitempty"`
Twitter string `json:"twitter,omitempty"`
WebsiteURL string `json:"website_url,omitempty"`
Organization interface{} `json:"organization,omitempty"`
JobTitle string `json:"job_title,omitempty"`
WorkInformation interface{} `json:"work_information,omitempty"`
LastSignInAt string `json:"last_sign_in_at,omitempty"`
ConfirmedAt string `json:"confirmed_at,omitempty"`
LastActivityOn string `json:"last_activity_on,omitempty"`
Email string `json:"email,omitempty"`
ThemeID int `json:"theme_id,omitempty"`
ColorSchemeID int `json:"color_scheme_id,omitempty"`
ProjectsLimit int `json:"projects_limit,omitempty"`
CurrentSignInAt string `json:"current_sign_in_at,omitempty"`
Identities []interface{} `json:"identities,omitempty"`
CanCreateGroup bool `json:"can_create_group,omitempty"`
CanCreateProject bool `json:"can_create_project,omitempty"`
TwoFactorEnabled bool `json:"two_factor_enabled,omitempty"`
External bool `json:"external,omitempty"`
PrivateProfile bool `json:"private_profile,omitempty"`
SharedRunnersMinutesLimit interface{} `json:"shared_runners_minutes_limit,omitempty"`
ExtraSharedRunnersMinutesLimit interface{} `json:"extra_shared_runners_minutes_limit,omitempty"`
IsAdmin bool `json:"is_admin,omitempty"`
Note interface{} `json:"note,omitempty"`
UsingLicenseSeat bool `json:"using_license_seat,omitempty"`
}
// 校验失败返回给用户的错误提示
const checkFailedUser = `##############################################
##
## 提交用户名与gitlab登录用户名不匹配, 请修改!
## git config --global user.name "%s"
##
## 修改后, 先操作撤销commit, 以下示例只是撤销一个
## git reset --soft HEAD~1
##
##############################################`
const checkFailedEmail = `##############################################
##
## 提交邮箱与gitlab登录邮箱不匹配, 请修改!
## git config --global user.email "%s"
##
## 修改后, 先操作撤销commit, 以下示例只是撤销一个
## git reset --soft HEAD~1
##
##############################################`
const checkFailedMsgLen = `##############################################
## 先撤销commit, 然后再进行commit提交。以下示例只是撤销一个
## git reset --soft HEAD~1
##
##############################################`
const (
// 是否开启严格模式,严格模式下将校验所有的提交信息格式(多 commit 下)
strictMode = true
// commit log format
CommitLogFormat = `{"commit":"%H","sanitized_subject_line":"%f","author":{"name":"%an","email":"%ae","timestamp":"%at"},"committer":{"name":"%cn","email":"%ce","timestamp":"%ct"}}`
// zero_commit
ZeroCommit = `0000000000000000000000000000000000000000`
)
// Gitlab API
const (
GITLAB_URL = "http://127.0.0.1"
GITLAB_TOKEN = "xxxxxxxxx"
)
var logger *log.Logger
// 日志打印到syslog
func init() {
sysLog, err := syslog.Dial("", "", syslog.LOG_ERR, "Saturday")
if err != nil {
log.Fatal(err)
}
logger = log.New(sysLog, "", 0777)
}
func getGitlabUserInfo(userName string) GitlabUserInfo {
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v4/users?username=%s", GITLAB_URL, userName), nil)
if err != nil {
logger.Println(err)
_, _ = fmt.Fprintln(os.Stderr, "GL-HOOK-ERR: exit code 9-1, 未知错误, 请联系管理员")
os.Exit(9)
}
req.Header.Set("Private-Token", GITLAB_TOKEN)
resp, err := http.DefaultClient.Do(req)
if err != nil {
logger.Println(err)
_, _ = fmt.Fprintln(os.Stderr, "GL-HOOK-ERR: exit code 9-2, 未知错误, 请联系管理员")
os.Exit(9)
}
defer resp.Body.Close()
bodyByte, err := ioutil.ReadAll(resp.Body)
if err != nil {
logger.Println(err)
_, _ = fmt.Fprintln(os.Stderr, "GL-HOOK-ERR: exit code 9-3, 未知错误, 请联系管理员")
os.Exit(9)
}
logger.Println(string(bodyByte))
var gitlabUserInfo []GitlabUserInfo
if err := json.Unmarshal(bodyByte, &gitlabUserInfo); err != nil {
logger.Println(err)
_, _ = fmt.Fprintln(os.Stderr, "GL-HOOK-ERR: exit code 9-4, 未知错误, 请联系管理员")
os.Exit(9)
}
if len(gitlabUserInfo) == 0 {
_, _ = fmt.Fprintln(os.Stderr, fmt.Sprintf("GL-HOOK-ERR: exit code 9-5, 未找到%s用户", userName))
os.Exit(9)
}
return gitlabUserInfo[0]
}
func getCommitRevList(commit string) ([]string, error) {
getCommitMsgCmd := exec.Command("git", "rev-list", commit, "--not", "--all")
getCommitMsgCmd.Stdin = os.Stdin
getCommitMsgCmd.Stderr = os.Stderr
b, err := getCommitMsgCmd.Output()
if err != nil {
return nil, err
}
commitList := strings.Split(string(b), "\n")
var res []string
for _, commits := range commitList {
if strings.ReplaceAll(commits, " ", "") == "" {
continue
}
res = append(res, commits)
}
return res, nil
}
func getCommitInfo(commit string) ([]byte, error) {
getCommitMsgCmd := exec.Command("git", "log", "-n", "1", commit, fmt.Sprintf(`--pretty=format:%s`, CommitLogFormat))
getCommitMsgCmd.Stdin = os.Stdin
getCommitMsgCmd.Stderr = os.Stderr
res, err := getCommitMsgCmd.Output()
if err != nil {
return nil, err
}
return res, nil
}
func getCommitMsg(commit string) (string, error) {
getCommitMsgCmd := exec.Command("git", "log", "-n", "1", commit, "--pretty=format:%s")
getCommitMsgCmd.Stdin = os.Stdin
getCommitMsgCmd.Stderr = os.Stderr
byteMsg, err := getCommitMsgCmd.Output()
if err != nil {
return "", err
}
res := strings.ReplaceAll(string(byteMsg), " ", "")
res = strings.ReplaceAll(string(byteMsg), " ", "")
res = strings.ReplaceAll(string(byteMsg), "\n", "")
return res, nil
}
func main() {
input, _ := ioutil.ReadAll(os.Stdin)
param := strings.Fields(string(input))
// allow branch/tag delete
if param[1] == ZeroCommit {
os.Exit(0)
}
// Check for new branch or tag
var span string
if param[0] == ZeroCommit {
span = param[1]
} else {
span = fmt.Sprintf("%s...%s", param[0], param[1])
}
commitRevList, err := getCommitRevList(span)
if err != nil || len(commitRevList) == 0 {
logger.Println(err)
os.Exit(0)
}
userName := os.Getenv("GL_USERNAME")
projectID := strings.Split(os.Getenv("GL_REPOSITORY"), "-")[1]
// get user email for gitlab api
userInfo := getGitlabUserInfo(userName)
for _, commit := range commitRevList {
commitInfo, err := getCommitInfo(commit)
if err != nil || len(commitInfo) == 0 {
logger.Println(err)
_, _ = fmt.Fprintln(os.Stderr, "GL-HOOK-ERR: exit code 444, can't get commit user info", commit, err)
os.Exit(444)
}
logger.Println(string(commitInfo))
var commits = Commits{}
if err := json.Unmarshal(commitInfo, &commits); err != nil {
logger.Println(err)
logger.Printf("error decoding sakura response: %v", err)
if e, ok := err.(*json.SyntaxError); ok {
logger.Printf("syntax error at byte offset %d", e.Offset)
}
logger.Printf("sakura response: %q", commitInfo)
logger.Printf("sakura response: %q", commitInfo)
_, _ = fmt.Fprintln(os.Stderr, "GL-HOOK-ERR: exit code 555 未知错误, 请联系管理员")
os.Exit(555)
}
// continue old commit
_t, err := strconv.Atoi(commits.Committer.Timestamp)
if err != nil {
logger.Println(err)
_, _ = fmt.Fprintln(os.Stderr, "GL-HOOK-ERR: exit code 777, 知错误, 请联系管理员")
os.Exit(777)
}
if _t < 1607936839 {
continue
}
logger.Println(userName, projectID, commits.Commit, commits.Author.Name, userName)
// check auth user name
//if commits.Author.Name != userName && commits.Author.Name != userInfo.Name {
// _, _ = fmt.Fprintln(os.Stderr, "GL-HOOK-ERR: 用户名不对: ", commits.Author.Name, ", 应为: ", userName)
// _, _ = fmt.Fprintln(os.Stderr, fmt.Sprintf(checkFailedUser, userName))
// os.Exit(999)
//}
logger.Println(userName, projectID, commits.Commit, commits.Author.Email, userInfo.Email)
//check auth user email
//if commits.Author.Email != userInfo.Email {
// _, _ = fmt.Fprintln(os.Stderr, "GL-HOOK-ERR: 邮箱不对: ", commits.Author.Email, ", 应为: ", userInfo.Email)
// _, _ = fmt.Fprintln(os.Stderr, fmt.Sprintf(checkFailedEmail, userInfo.Email))
// os.Exit(999)
//}
logger.Println(userName, projectID, commits.Commit, commits.Committer.Name, userName)
// check committer user name
if commits.Committer.Name != userName && commits.Committer.Name != userInfo.Name {
_, _ = fmt.Fprintln(os.Stderr, "GL-HOOK-ERR: 用户名不对: ", commits.Committer.Name, ", 应为: ", userName)
_, _ = fmt.Fprintln(os.Stderr, fmt.Sprintf(checkFailedUser, userName))
os.Exit(999)
}
logger.Println(userName, projectID, commits.Commit, commits.Committer.Email, userInfo.Email)
// check committer user email
if commits.Committer.Email != userInfo.Email {
_, _ = fmt.Fprintln(os.Stderr, "GL-HOOK-ERR: 邮箱不对: ", commits.Committer.Email, ", 应为: ", userInfo.Email)
_, _ = fmt.Fprintln(os.Stderr, fmt.Sprintf(checkFailedEmail, userInfo.Email))
os.Exit(999)
}
commits.Subject, err = getCommitMsg(commits.Commit)
if err != nil {
logger.Println(err)
logger.Println("con't get commit message")
os.Exit(0)
}
// check message length
if len(commits.Subject) < 8 {
_, _ = fmt.Fprintln(os.Stderr, "GL-HOOK-ERR: Commit message太短, 没有任何意义")
_, _ = fmt.Fprintln(os.Stderr, checkFailedMsgLen)
os.Exit(666)
}
if !strictMode {
os.Exit(0)
}
}
}