简介

大多数选择 Hugo 这类静态博客的用户,会选择将博客存储到 GitHub Pages 之类的方案,其中初学者一般会在本地生成网站,而进阶用户或者懒人一般会使用 CI 来实现自动部署。而我,由于自身性格别扭,以及将网站源代码放在了私有库中,并基于一些其它的神秘原因选择了通过 GitHub Webhook 再加上用 Golang 编写的后台服务来在自己的 VPS 上实现自动部署。

流程

  • 抽象而言
    Git 仓库更新后触发钩子 => (通过反向代理) => 接收钩子的服务 => 进行处理
  • 具体到个人的选择
    GitHub Webhook => (Nginx) => Golang API Server => ...

配置 GitHub Repository

将整个网站添加到仓库

基本操作就不详谈了,记得把这两行加进 .gitignore,因为我们只需要网站的源代码,而不需要生成后的内容。

/public
/resources

添加 Webhook

  • 首先前往 https://gitHub.com/{user}/{repository}/settings/hooks/new (自行替换 {user}{repository})。

    Add Webhook

  • Payload URL 这里填写接收钩子的 URL,例如 https://api.coda.world/webhookpath,实际上强烈建议用一串复杂的字符代替 webhookpath (例如我后面的代码中的 webhookpath_zbthvqemo08kvhds),可以防止钩子被别人探嗅到然后恶意触发。

  • Content type 建议使用 application/json

  • Secret 可以使用下方的命令生成一个

    head /dev/urandom | tr -dc A-Za-z0-9 | head -c 32 ; echo ''
    

    如果前面设置了复杂的 URL 或者使用防火墙限制 IP 等其他方式以确保钩子不会被恶意触发,不设置 Secret 也是可以的。

  • 其它选项保持默认,最后点 Add webhook 添加即可。

设置 Deploy keys (可选)

  • 由于我使用的是私有库,所以不能直接 Pull,而响应钩子的服务应当使用独立的密钥来进行 Pull Request,所以要在这里进行设置 https://gitHub.com/{user}/{repository}/settings/keys/new (同样自行替换 {user}{repository})。
  • 新生成一个 SSH Key,并将 Public Key 填写到这里,而 Private Key 自然由响应钩子的服务来使用。
  • Allow write access 通常不需要选上,当然如果你的服务需要进行 Push 的话除外。

配置 Nginx 反向代理(推荐)

反向代理还是推荐配置一下的,毕竟也不复杂,比起来直接使用 IP 加端口号也更安全,毕竟给 IP 地址直接配上 SSL 证书可以说是不现实的。
直接贴一下部分 Nginx 配置

server {
    server_name api.coda.world;
    listen 443 ssl http2;
    ssl_certificate /.../cert.pem;
    ssl_certificate_key /.../key.pem;
    location / {
        proxy_pass_request_headers on;
        proxy_pass http://127.0.0.1:11235;
    }
}

Golang 后台服务

准备工作

首先准备相应的用户,添加之前生成的 SSH 密钥,把 github.com 的 fingerprint 提前写入 known_hosts,将仓库克隆下来。

useradd githubwebhook
sudo su - githubwebhook
cd
ssh-add ...
ssh-keygen -F github.com || ssh-keyscan github.com >> ~/.ssh/known_hosts
git clone ...

最后记得把自己存放网站的目录的所有者设为 githubwebhook:githubwebhook,以及设置好相应的权限,否则可能会出现生成完网站后因为权限问题没法 rsync,或者 Nginx 因为权限不足没法读取内容的尴尬情景。

Golang

先放出实现最低功能的代码,后面再解释。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
package main

import (
	"fmt"
	"net/http"
	"os/exec"
)

var hookPort string = "11235"
var hookPath string = "/webhookpath_zbthvqemo08kvhds"
var repoPath string = "/home/githubwebhook/myrepopath/"
var sitePath string = "/var/www/mywebsite/"

func githubwebhook(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintf(w, `{"message":"Hook Received"}`)
	go func() {
		git := exec.Command("git", "pull")
		git.Dir = repoPath
		if _, err := git.Output(); err != nil {
			fmt.Println(err)
			return
		}
		hugo := exec.Command("hugo", "--cleanDestinationDir",
			"--minify", "--gc", "--destination", sitePath)
		hugo.Dir = repoPath
		if _, err := hugo.Output(); err != nil {
			fmt.Println(err)
		}
	}()
}

func main() {
	http.HandleFunc(hookPath, githubwebhook)
	http.ListenAndServe(":"+hookPort, nil)
}

代码就这么短,做的事情也很简单:

  • 监听 11235 端口,收到 HTTP 请求后返回一个简单的响应(这样 GitHub Webhook 那边就不会显示响应超时)。
  • 然后把 /home/githubwebhook/myrepopath/ 作为工作目录依次执行 git pullhugo ...
  • 仅仅这些!没错,根本没管前面设置的 Content Type 以及 Secret,事实上除了收到请求这一点以外其它的全被忽略了。当然实际上在这种简单的场景下也确实用不到 Payload 中的那些信息,而忽略 Secret 的条件是代码中 webhookpath_zbthvqemo08kvhds 这个 path 设置的比较复杂,很难收到仿造的请求。

自行更改一下高亮部分中的变量,然后测试一下没什么大问题后就可以编译二进制文件了。

systemd

既然是后台服务,unit file 自然少不了,同样直接放简化版。

# /etc/systemd/system/githubwebhook.service
[Unit]
Description=GitHub Webhook Server
Require=syslog.target network.target Nginx.service
After=syslog.target network.target Nginx.service

[Service]
Type=simple
User=githubwebhook
Group=githubwebhook
ExecStart=/usr/local/bin/githubwebhook-go-bin
Restart=always
StandardOutput=syslog
StandardError=syslog
SyslogIdentifier=GitHubWebhook

[Install]
WantedBy=multi-user.target

可以改进的地方

  • 由于改进会导致代码的急速膨胀,所以只会大概提一下思路。
  • 首先 HTTP 响应只是个简单的 "hook received",事实上可以根据情况不同返回相应的 status code 以及内容,不过需要注意的是响应前耗费的时间太多可能被 GitHub 认为响应超时。
  • 没有使用 Secret 进行哈希验证,如果想进行验证,可以参考官方文档来了解如何对比使用 Secret 生成的哈希值。
  • 可以在更新完网站后 Ping 一下 googlesitemap 等服务,告知搜索引擎网站更新了。
  • 为了精简代码,Golang 代码中的一些路径和端口号等都是写死在代码中的,实际上更推荐在 service file 里加上 EnvironmentFile=...,然后把这些变量写到 EnvironmentFile 中,并在程序中使用 os.Getenv("VARIABLE") 来读取。注意 EnvironmentFile 的权限,因为可能还包含了其它 API 的密钥之类的东西。
  • 在进行大量后续处理后,总是希望可以方便的看到日志的,然而就像前面提到过的,这时候再发送响应肯定是已经超时的。最省事的办法是直接把日志做成一个网页,可以用简单的文件名以方便访问,也可以用复杂的文件名然后把链接放到 readme.md 中。由于我使用的是私有库,所以我直接把后端的所有输出都写进了 readme.md,然后再 push 回去,当然这样做需要处理两个问题。首先是 push 回去的时候也会触发一遍 webhook,所以需要在收到请求时检查一下是不是由于更新 readme.md 导致。其次是会导致 Race Condition,这个问题当然可以用分支之类的方案解决,不过实际使用中由于只是个人站点所以其实注意一下即可。
  • 最后还是再次强调一下,如果网站本身不是放在私有库中或者愿意为 CI 掏钱,直接使用成熟的 CI 方案会更省心省力。