Fork me on GitHub

Обработка вебхуков GitHub с помощью Nginx и Lua Mar 20, 2017

После того как принимается 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

В настройках репозитория создадим новый вебхук и направим его на наш локейшн.

gh hook

Валидация запроса

Проверка корректности запроса. В первую очередь нужно убедиться, что запрос действительно 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.