Webhook生成静态博客

于 2023-10-09 发布 , 于 2023-10-10 更新

当静态博客生成器和Git都是实机部署的时候,直接用post-recieve这个hook来调用生成器是非常方便的事情。但是当Git是用docker部署,或者是Gitlab这样不支持repository hook的解决方案的场景下,就需要用webhook来调用生成器了。本文会介绍以下内容:

  1. 如何写uwsgi作为响应webhook的backend
  2. 如何生成token以无密码clone private仓库

本文假设已经按照 archlinux部署gitlab 完成部署。

UWSGI安装

由于处理响应的是python写的程序,所以除了安装uwsgi之外还需要安装python插件:sudo pacman -S uwsgi uwsgi-plugin-python

安装完成之后不需要做其他操作,因为我们后续会用systemd service的方式托管,不需要用到uwsgi自己的什么“emperor”模式。

webhook设置

GitLab

解决webhook无法请求本机的问题

默认安装下gitlab是不允许webhook请求localhost的地址的,也就是不能在URL里填127.0.0.1+端口。解决这个问题有两个办法,一是简单粗暴允许所有webhook访问localhost,但是会有安全隐患,所以放在后面说;二是用让nginx监听一个server_name,把server_name填进allowlist里面。

首先来配置nginx,顺便把转发给python的部分也写上。假设我们的repo在收到push之后,会发送一个webhook给http://blogbuilder.internal:25666,那么nginx的server配置就应该这么写:

1
2
3
4
5
6
7
8
9
server {
  listen 127.0.0.1:25666;
  server_name blogbuilder.internal;
  location / {
    include uwsgi_params;
    # 我们的uwsgi应用监听这个9091端口
    uwsgi_pass 127.0.0.1:9091;
  }
}

测试并重载nginx:sudo nginx -t && sudo systemctl reload nginx

然后需要配置dns,让dns解析器响应blogbuilder.internal127.0.0.1。这个因使用的DNS软件而异,我用的mosdns就只需要在hosts插件里新增一条记录就行了。配置好之后ping blogbuilder.internal应该有如下显示:

1
2
3
➜ ping blogbuilder.internal
PING blogbuilder.internal (127.0.0.1) 56(84) bytes of data.
64 bytes from localhost (127.0.0.1): icmp_seq=1 ttl=64 time=0.017 ms

最后就是配置GitLab把blogbuilder.internal放进allowlist。登录GitLab管理员账户之后,点击Configure GitLab,左侧栏点击Settings下面的Network,然后往下拉到Outbound requests:

image-20231010130917738

image-20231010140856586

然后要按Save changes。

给仓库添加webhook

打开一个仓库,左侧边栏最下面有个Settings,里面有个Webhooks,点击之后是这个页面:

image-20231010130723013

URL填写:http://blogbuilder.internal:25666

Trigger勾选Push events,如果有多个branch可以自行设置pattern

拉到最下面,SSL verification可以取消勾,因为我们是本机http请求,没有ssl。然后点击Add webhook按钮,没有问题的话就会添加成功了。

image-20231010141259500

GitLab这边就设置完了,接下来就是编写响应的部分了。

Python

application

网上关于uwsgi的文章很多,但是都说得有点谜语人。我们用uwsgi的主要原因是为了能够让build和respond异步执行,也就是说能够尽快回应200给GitLab,让博客生成在后面慢慢摸。

那么首先我们就需要用到subprocess来启动独立进程执行博客生成器,另外还需要shlex帮忙拆分shell命令给subprocess。

1
2
3
#!/usr/bin/env python3
import subprocess
import shlex

uwsgi的入口函数是application,需要包含两个东西:1,响应码;2,返回一个list(包含生成的content)。

1
2
3
def application(environ, start_response):
  start_response('200',[])
  return []

接下来填入调用我们的外部shell script(负责build的琐碎东西)的命令:

1
2
3
4
5
6
7
8
9
def application(environ, start_response):
  start_response('200',[])
  # 使用env -i来去掉所有的环境变量,主要是避免http_proxy干扰DNS
  # 毕竟我们要用本地DNS来解析、HTTP直连本机的GitLab,避免绕路公网转发
  cmd = "env -i ./build.sh"
  cmds = shlex.split(cmd)
  # 用Popen来detach执行shell script,避免阻塞
  subprocess.Popen(cmds,start_new_session=True)
  return []

但是这样的话是不辨别webhook发送的什么内容就直接触发生成。一般来说还是要判断一下发了什么过来的,这就需要用到environ变量了:

1
2
3
4
5
6
7
8
9
10
def application(environ, start_response):
    start_response('200',[])
    # keys = environ.keys()
    # for key in keys:
    #     print("{}: {}".format(key, environ[key]))
    if environ['REQUEST_METHOD'] == "POST" and environ['HTTP_X_GITLAB_EVENT'] == "Push Hook":
        cmd = "env -i ./build.sh"
        cmds = shlex.split(cmd)
        subprocess.Popen(cmds,start_new_session=True)
    return []

如果需要inspect一下environ包含了什么内容,可以把注释掉的那几行拿出来用——建议在真正写执行部分之前自己搞清楚environ的内容,毕竟有时候文档写的跟你真正收到的内容是不一样的。

假设我们把以下内容放在了/opt/autogenblog/application.py里面:

1
2
3
4
5
6
7
8
9
10
11
12
13
#!/usr/bin/env python3
import subprocess
import shlex
def application(environ, start_response):
    start_response('200',[])
    # keys = environ.keys()
    # for key in keys:
    #     print("{}: {}".format(key, environ[key]))
    if environ['REQUEST_METHOD'] == "POST" and environ['HTTP_X_GITLAB_EVENT'] == "Push Hook":
        cmd = "env -i ./build.sh"
        cmds = shlex.split(cmd)
        subprocess.Popen(cmds,start_new_session=True)
    return []

systemd

那么现在就要写一个systemd服务来启动这个uwsgi了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
[Unit]
Description=Auto generate hugo content
After=network.target nss-lookup.target

[Service]
PrivateTmp=true
ProtectSystem=full
ProtectKernelTunables=true
NoNewPrivileges=true
User=blogbuilder
Group=http
ReadWritePaths=/var/www/blog
WorkingDirectory=/opt/autogenblog
ExecStart=/usr/bin/uwsgi --socket 127.0.0.1:9091 --plugin python --wsgi-file /opt/autogenblog/application.py --master --processes 2 --threads 2
KillSignal=SIGQUIT
Restart=always
[Install]
WantedBy=multi-user.target

这些内容需要放到/etc/systemd/system/autogenblog.service里。它的内容(Service部分)需要慢慢解释:

  1. 开头四行是安全防护的内容,含义参考systemd.exec(5)

  2. User用blogbuilder,如果还没有这个用户,sudo useradd --no-user-group --create-home --system blogbuilder

  3. Group用Nginx的http组

  4. 由于开头4行的保护,整个系统除了/etc/tmp都是不可写入的,但是我们的博客需要写入/var/www/blog来让nginx对外serve content,所以在ReadWritePaths指定该路径

  5. 由于2、3、4点,所以/var/www/blog的权属就应该是blogbuilder:http,权限是755,请用chownchmod来修正这些权限。

    1
    2
    3
    4
    5
    
    ls /var/www/blog -alh 
    total 36K
    drwxrwxr-x  3 blogbuilder http  28K Oct 10 14:16 .
    drwxr-xr-x  7 http        http 4.0K Oct  4 14:38 ..
    drwxr-xr-x 20 blogbuilder http 4.0K Oct 10 14:16 public
    
  6. WorkingDirectory工作目录设置成application.py所在的目录,以便cmd = "env -i ./build.sh"能够调用正确的build.sh

  7. ExecStart=指定的socket就是我们在nginx的server里指定的uwsgi_pass的值,wsgi-file是我们的application.py的完整路径

  8. KillSignal是因为如果不指定的话,uwsgi主进程会等待60秒才强制杀掉所有子进程,这就有点太久了

现在就可以启动并开机启动这个service了sudo systemctl enable --now autogenblog.service

build.sh

做完前面的所有东西之后,现在就只差一个uwsgi调用的build.sh需要编写了。但是因为我们的blog仓库是private的(除非你真的开诚布公,没有任何secret,否则不建议把博客源代码都设置成public仓库),所以需要生成GitLab Access Token来让脚本可以不用密码来clone。

Access Token

点开博客仓库,左边最下面的Settings,点Access Token,填Token name,role改成Maintainer(不然会被deny),scope勾上read_repository,然后点create就行。把token抄下来,不然切了页面就没办法再显示了,需要重新生成了。

image-20231010160321394

token的用法是:

如果不是本机直连访问,请务必使用https,否则明文传输很危险。

1
git clone https://用户名:token@git.mutebot.net/用户/仓库名.git

build.sh

根据以上内容就可以编写这个build脚本了。具体内容请替换成自己的实际内容:

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
#!/usr/bin/env bash
set -e
GIT_NAME=你的gitlab用户名
GIT_TOKEN=你的token
GIT_REPO=你的博客repo名字
TMP_GIT_CLONE=/tmp/blog_repo

set +e
# 清理临时目录
rm -rf $TMP_GIT_CLONE

# 这里用http是因为本机直连本机的gitlab
# 主机名也注意改一下,如果你不是git.mutebot.net的用户
# 这里还指定了branch,如果你的branch不叫master请自己改
# 用depth参数指定只clone最新的commit
git clone http://"$GIT_NAME":"$GIT_TOKEN"@git.mutebot.net/"$GIT_NAME"/"$GIT_REPO".git $TMP_GIT_CLONE -b master --depth=1
# 切换到临时目录
pushd $TMP_GIT_CLONE
set -e
# 生成博客内容
hugo
set +e
# 清理nginx的博客文件目录
rm -rf /var/www/blog/*
set -e
# 把新的内容放到nginx目录里
mv public /var/www/blog
# 退出临时目录
popd
set +e
# 清理临时目录
rm -rf $TMP_GIT_CLONE
exit

把这个文件保存到application.py所在的目录,按本文的路径就是/opt/autogenblog/build.sh。记得加上可执行权限sudo chmod +x build.sh

想要测试的话可以用webhook页面拉到最下面有个test,选择push的test就行。用sudo journalctl -u autogenblog --since "1m ago" -f应该可以看到hugo命令运行的输出以及整个脚本执行的结果。再执行sudo ls -alh /var/www/blog就能够看到更新时间应该是非常近的。

Tips and Tricks

允许webhook访问任何本地网络的资源

如果整个实例只有自己一个人用的话,直接允许webhook访问本地网络也不会有多大风险。毕竟这个选项带来的风险主要是:有些本地网络的resource并不是开放给其他用户的,但是如果开启了这个选项,其他用户就可以通过用webhook来非法访问这些resource。

登录GitLab管理员账户之后,点击Configure GitLab,左侧栏点击Settings下面的Network,然后往下拉到Outbound requests,把webhooks那个勾勾上,按保存就可以了。

image-20231010134720015

参考文献

  1. Personal access tokens - GitLab Docs
  2. Project access tokens - GitLab Docs
  3. uWSGI - archwiki
  4. Sanitize environment with command or bash script?

目录