После того как принимается pull-request
и наработки кода попадают в мастер, нужно обновить сервер, выполнив на нем команды деплоя. Обычно у нас эта обязанность возложена на сервер CI TeamCity
, который в случае успешного билда мастер ветки накатывает изменения на продакшен сервера. Но иногда не нужно такое сложное взаимодействие, а достаточно просто знать факт пуша в мастер и обработать его самостоятельно. С помощью вебхуков GitHub
может уведомлять о push событиях в репозитории. Но для валидации и обработки этих запросов нужен какой-либо бекенд. В этом мне помогает знакомая связка Nginx
+ Lua
.
Предварительная настройка
Нам понадобятся Nginx
с модулем lua-nginx-module. И две дополнительные библиотеки для lua
. Для того чтобы прочесть json
тело запроса используем JSON4Lua, а для валидации подписи LuaCrypto. Установим их через менеджер пакетов luarocks.
$ sudo luarocks install JSON4Lua
$ sudo luarocks install luacrypto
Подробнее о том как настроить Nginx
и Lua
можно прочитать в статье.
Наш сервер
Сконфигурируем локейшн для принятия вебхука.
server {
# ...
location /deploy {
client_body_buffer_size 3M;
client_max_body_size 3M;
content_by_lua_file /path/to/handler.lua;
}
}
Важно установить значения client_body_buffer_size
и client_max_body_size
одинаковыми. Для корректного чтения тела запроса и предотвращения ошибки работы с временным файлом.
lua entry thread aborted: runtime error: requesty body in temp file not supported
GitHub hooks
В настройках репозитория создадим новый вебхук и направим его на наш локейшн.
Валидация запроса
Проверка корректности запроса. В первую очередь нужно убедиться, что запрос действительно POST
. Сделаем это с помощью функции req.get_method.
-- should be POST method
if ngx.req.get_method() ~= "POST" then
ngx.log(ngx.ERR, "wrong event request method: ", ngx.req.get_method())
return ngx.exit (ngx.HTTP_NOT_ALLOWED)
end
Следующим шагом проверим, что этот запрос содержит заголовок с хук методом. Получив все заголовки с помощью функции req.get_headers.
local event = 'push'
-- ...
local headers = ngx.req.get_headers()
-- with correct header
if headers['X-GitHub-Event'] ~= event then
ngx.log(ngx.ERR, "wrong event type: ", headers['X-GitHub-Event'])
return ngx.exit (ngx.HTTP_NOT_ACCEPTABLE)
end
Так как мы будем слушать вебхуки в формате json
, проверим контент тип запроса.
-- should be json encoded request
if headers['Content-Type'] ~= 'application/json' then
ngx.log(ngx.ERR, "wrong content type header: ", headers['Content-Type'])
return ngx.exit (ngx.HTTP_NOT_ACCEPTABLE)
end
По первичным признакам запрос корректный. Проанализируем тело запроса. С помощью функций req.read_body и req.get_body_data.
-- read request body
ngx.req.read_body()
local data = ngx.req.get_body_data()
if not data then
ngx.log(ngx.ERR, "failed to get request body")
return ngx.exit (ngx.HTTP_BAD_REQUEST)
end
Проверим корректность подписи запроса, которая передается в заголовке X-Hub-Signature
, используя функцию verify_signature.
-- validate GH signature
if not verify_signature(headers['X-Hub-Signature'], data) then
ngx.log(ngx.ERR, "wrong webhook signature")
return ngx.exit (ngx.HTTP_FORBIDDEN)
end
Последний шаг - убедиться, что этот push
был в интересующей нас ветке. Разобрав тело запроса в таблицу lua
с помощью функции json.decode.
local branch = 'refs/heads/master'
-- ...
data = json.decode(data)
-- on master branch
if data['ref'] ~= branch then
ngx.say("Skip branch ", data['ref'])
return ngx.exit (ngx.HTTP_OK)
end
Проверка подписи
Для подтверждения корректности запроса, GitHub
использует HMAC
SHA1
подпись и передает её в заголовке X-Hub-Signature
, пример в документации.
Вызовем функцию crypto.hmac.digest и сравним её результат с полученным заголовком.
local secret = '<MY SUPER SECRET>'
-- ...
local function verify_signature (hub_sign, data)
local sign = 'sha1=' .. crypto.hmac.digest('sha1', data, secret)
return hub_sign == sign
end
Константное сравнение строк
Простое сравнение строк на ==
использовать не рекомендуется, т.к. злоумышленник может провести атаку по времени.
В lua
нельзя просто так взять и обратиться к строке по индексу. Так что для удобства внедрим данную функцию в метакласс строки.
getmetatable('').__index = function (str, i)
return string.sub(str, i, i)
end
Напишем функцию сравнения строк за “константное” время. Строки равны тогда и только тогда, когда равны посимвольно.
local function const_eq (a, b)
-- Check is string equals, constant time exec
local equal = string.len(a) == string.len(b)
for i = 1, math.max(string.len(a), string.len(b)) do
equal = (a[i] == b[i]) and equal
end
return equal
end
Автоматический деплой
В случае, если webhook
прошел все валидации, можно ему доверять. Через системный вызов io.popen выполним необходимые команды деплоя. В данном примере осуществляется простой pull
из репозитория.
local function deploy ()
local handle = io.popen("cd /path/to/repo && sudo -u username git pull")
local result = handle:read("*a")
handle:close()
ngx.say (result)
return ngx.exit (ngx.HTTP_OK)
end
Полный пример handler.lua
можно посмотреть в gist или вопросе на StackOverflow.