http://habrahabr.ru/post/228317/
Если какая-либо операция превращается в рутину — автоматизируй её. Даже если времени потратишь больше — зато ты занимался не рутиной, а интересным делом. Именно под этой вывеской вместо того, чтобы просто запатчить новые 11 версий rtsp_streamer'а для камер от
TopSee, решил нарисовать автопатчер. Идеальным языком для любых наколенных изделий я считаю питон — достаточно лаконично, достаточно жестко по читабельности (хотя я всё равно умудряюсь сделать его не читаемым). В общем, сейчас я расскажу, как с помощью палки и верёвки за один вечер научиться рисовать автопатчеры.
Итак, главные требования к скриптам на коленке — максимальное соответствие ожиданиям. Он должен либо работать, либо сообщать что что-то не то. Главная ошибка таких скриптов — это какие-либо действий без проверок на соответствие ожиданиям. Так как иначе можно не заметить что что-то поменялось и требуется вмешательство человека.
Итак, вспомним что мы
делали в прошлый раз, и пройдёмся еще раз весь путь вручную:
1) Грузим файл в дизассемблер
2) Находим функцию fctnl
3) Проходим по вызовам в поисках использования fcntl с O_NONBLOCK — находим две функции, makeSocketBlocking и makeSocketNonBlocking
Скрытый текстBoolean makeSocketNonBlocking(int sock) {
#if defined(__WIN32__) || defined(_WIN32)
unsigned long arg = 1;
return ioctlsocket(sock, FIONBIO, &arg) == 0;
#elif defined(VXWORKS)
int arg = 1;
return ioctl(sock, FIONBIO, (int)&arg) == 0;
#else
int curFlags = fcntl(sock, F_GETFL, 0);
return fcntl(sock, F_SETFL, curFlags|O_NONBLOCK) >= 0;
#endif
}
Boolean makeSocketBlocking(int sock) {
#if defined(__WIN32__) || defined(_WIN32)
unsigned long arg = 0;
return ioctlsocket(sock, FIONBIO, &arg) == 0;
#elif defined(VXWORKS)
int arg = 0;
return ioctl(sock, FIONBIO, (int)&arg) == 0;
#else
int curFlags = fcntl(sock, F_GETFL, 0);
return fcntl(sock, F_SETFL, curFlags&(~O_NONBLOCK)) >= 0;
#endif
}
4) Ищем в коде функцию sendPacket() (по прошлому мы знаем, что в неё вписаны отладочные printf'ы, по которым найти не составляет труда)
Скрытый текстBoolean RTPInterface::sendPacket(unsigned char* packet, unsigned packetSize) {
Boolean success = True; // we'll return False instead if any of the sends fail
for (tcpStreamRecord* streams = fTCPStreams; streams != NULL;
streams = streams->fNext) {
if (!sendRTPOverTCP(packet, packetSize, streams->fStreamSocketNum, streams->fStreamChannelId)) {
printf("%s(): ", "sendPacket");
printf("sendRTPOverTCP failed, sock: %d, chn: %d\r\n", streams->socket, streams->fStreamChannelId);
success = False;
}
}
return success;
}
5) Патчим функцию
Скрытый текстBoolean RTPInterface::sendPacket(unsigned char* packet, unsigned packetSize) {
Boolean success = True; // we'll return False instead if any of the sends fail
for (tcpStreamRecord* streams = fTCPStreams; streams != NULL;
streams = streams->fNext) {
makeSocketBlocking(streams->socket);
Boolean res = sendRTPOverTCP(packet, packetSize, streams->fStreamSocketNum, streams->fStreamChannelId);
makeSocketNonBlocking(streams->socket);
if (!res) {
success = False;
}
}
return success;
}
Итак, вот это всё нам нужно автоматизировать так, чтоб запустил и получил. Причем, в разных версиях прошивки используются где-то fcntl, где-то fcntl64; в зависимости от опций компилятора разные регистры использованы, небольшие другие отличия по оттранслированному коду наблюдаются.
Итак, начнём писать наш скрипт. Понятное дело, так как патчить будем разные версии, имя патчуемого файла передавать надо аргументом. Значит скрипт начался:
import sys
fname = sys.argv[1]
Так как мы делаем скрипт для себя, файлы небольшие (~ метр весом), памяти много, поэтому не будем заморачиваться с беготнёй по файлу — загрузим всё сразу в память:
f = open(fname, "r+b")
f.seek(0, 2)
size = f.tell()
f.seek(0, 0)
fw = f.read(size)
f.close()
Начнём искать функции makeSocketBlocking/makeSocketNonBlocking. Функции fcntl используются много где, так что реальная зацепка — это O_NONBLOCK (=0x800). С другой стороны, это библиотечные функции, которые никто не трогает, они идут подряд, можно просто найти функции «как есть»:
.text:0003C554 makeSocketBlocking
.text:0003C554 10 40 2D E9 STMFD SP!, {R4,LR}
.text:0003C558 03 10 A0 E3 MOV R1, #F_GETFL ; cmd
.text:0003C55C 00 20 A0 E3 MOV R2, #0
.text:0003C560 00 40 A0 E1 MOV R4, R0
.text:0003C564 32 39 FF EB BL fcntl
.text:0003C568 04 10 A0 E3 MOV R1, #F_SETFL ; cmd
.text:0003C56C 02 2B C0 E3 BIC R2, R0, #O_NONBLOCK
.text:0003C570 04 00 A0 E1 MOV R0, R4 ; fd
.text:0003C574 2E 39 FF EB BL fcntl
.text:0003C578 00 00 E0 E1 MVN R0, R0
.text:0003C57C A0 0F A0 E1 MOV R0, R0,LSR#31
.text:0003C580 10 80 BD E8 LDMFD SP!, {R4,PC}
.text:0003C580 ; End of function makeSocketBlocking
.text:0003C584 makeSocketNonblocking ; CODE XREF: sub_43524+40p
.text:0003C584 ; .text:00043608p ...
.text:0003C584 10 40 2D E9 STMFD SP!, {R4,LR}
.text:0003C588 03 10 A0 E3 MOV R1, #3 ; cmd
.text:0003C58C 00 20 A0 E3 MOV R2, #0
.text:0003C590 00 40 A0 E1 MOV R4, R0
.text:0003C594 26 39 FF EB BL fcntl
.text:0003C598 04 10 A0 E3 MOV R1, #4 ; cmd
.text:0003C59C 02 2B 80 E3 ORR R2, R0, #0x800
.text:0003C5A0 04 00 A0 E1 MOV R0, R4 ; fd
.text:0003C5A4 22 39 FF EB BL fcntl
.text:0003C5A8 00 00 E0 E1 MVN R0, R0
.text:0003C5AC A0 0F A0 E1 MOV R0, R0,LSR#31
.text:0003C5B0 10 80 BD E8 LDMFD SP!, {R4,PC}
.text:0003C5B0 ; End of function makeSocketNonblocking
Скопируем этим опкоды в лоб, и заменим все параметры, которые отличаются от версии, адреса, настроек компилятора и тд.
blockMask = """
; makeSocketBlocking
mm
?? ?? 2D E9 ; STMFD SP!, ....
03 10 A0 E3 ; MOV R1, #3 ; cmd
00 20 A0 E3 ; MOV R2, #0
00 ?? A0 E1 ; MOV Rx, R0 ; save fd
?? ?? FF EB ; BL fcntl
04 10 A0 E3 ; MOV R1, #4 ; cmd
02 2B C0 E3 ; clear O_NONBLOCK
?? 00 A0 E1 ; restore fd
?? ?? FF EB ; BL fcntl
00 00 E0 E1 ; MVN R0, R0
A0 0F A0 E1 ; MOV R0, R0,LSR#31
?? ?? BD E8 ; LDMFD SP!, ....
mm
; makeSocketNonblocking
?? ?? 2D E9
03 10 A0 E3
00 20 A0 E3
00 ?? A0 E1
?? ?? FF EB
04 10 A0 E3
02 2B 80 E3
?? 00 A0 E1
?? ?? FF EB
00 00 E0 E1
A0 0F A0 E1
?? ?? BD E8
"""
Я дополнительно поставил метки («mm») к началам функций; все не-опкоды закомментировал ";" и так и оставил в коде как есть (на будущее, чтоб вспомнить что есть что).
Теперь надо превратить эту строку в маску для поиска. Разумеется, вручную заниматься поиском совершенно не охото, поэтому искать будем через регекспы, благо они отлажены и оптимизированы по самое не балуйся. Да и весь файл мы прочитали в память как одну большую строку — что тоже удобно. Посему пишем функцию, которая преобразует эту маску в регексп:
import re
def maskToRegex(mask):
mask = re.sub( ";.*$", "", mask, flags=re.MULTILINE)
mask = re.sub( "\s+", "", mask, flags=re.MULTILINE)
masks = re.findall( "..", mask)
rgx = ""
for m in masks:
if m == "??":
rgx += "."
elif m == "mm":
rgx += "()"
else:
rgx += "\\x"+m
return rgx
Поведение простое — удаляем все комментарии (всё от; до конца строки), удаляем все пробелы, оставшиеся символы рубим на пары, и смотрим — если это ?? — то заменяем на точку (любой символ), если «mm» — вставляем метку, для которой будем запоминать положение, иначе генерируем код символа (приписав "\x" к этой паре).
Итак, у нас есть маска и мы можем получить из неё регексп, давайте найдём наконец эти функции:
### 1. Find offset of makeSocketBlocking and makeSocketNonblocking
makeBlock = None
makeNonBlock = None
for find in re.finditer(maskToRegex(blockMask), fw, re.DOTALL):
if makeBlock is None and makeNonBlock is None:
makeBlock = find.start(1)
makeNonBlock = find.start(2)
print "Found makeNonBlock at ", hex(makeNonBlock)
print "Found makeBlock at ", hex(makeBlock)
else:
print "Non-unqiue makeNonBlock/makeBlocking functions found"
break
if makeBlock is None or makeNonBlock is None:
print "makeNonBlock/makeBlocking functions not found"
В данном коде два важных момента. Во-1х, глобальный, соответствие ожиданиям. Мы проверяем, что под эту маску попал только один блок кода, и что он действительно попал. На время отладки добавляем распечатку смещений найденных мест. Во-2х, важно не забыть про re.DOTALL, чтобы под точку попадал действительно любой байт, мы работаем с бинарной строкой.
Итак, теперь нам надо найти функцию sendPacket. Заглянем в дизассемблятину:
.text:0006C9A0 SendPacket ; CODE XREF: sub_69FB8+144p
.text:0006C9A0 ; .text:0006D144p ...
.text:0006C9A0 F0 4F 2D E9 STMFD SP!, {R4-R11,LR}
.text:0006C9A4 00 60 A0 E1 MOV R6, R0
.....
.text:0006CB78 C3 FF FF 1A BNE loc_6CA8C
.text:0006CB7C E2 FF FF EA B loc_6CB0C
.text:0006CB7C ; End of function SendPacket
.text:0006CB80 BC 92 0A 00 off_6CB80 DCD aS_10 ; DATA XREF: SendPacket+7Cr, SendPacket+F0r
.text:0006CB80 ; "%s(): "
.text:0006CB84 E4 6B 0A 00 off_6CB84 DCD aSendpacket ; DATA XREF: SendPacket+80r, SendPacket+F4r
.text:0006CB84 ; "sendPacket"
.text:0006CB88 00 9B 0A 00 off_6CB88 DCD aSendrtpovert_0 ; DATA XREF: SendPacket+98r
.text:0006CB88 ; "sendRTPOverTCP failed, sock: %d, chn: %"...
.text:0006CB8C 2C 9B 0A 00 off_6CB8C DCD aRemovestreamso ; DATA XREF: SendPacket+110r
.text:0006CB8C ; "removeStreamSocket, sock: %d, chnid: %d"...
Ага, ссылка на эту строку лежит сразу после кода функции. Значит, чтобы найти функцию, нам надо: найти строку с именем функции (спасибо отладочным макросам, она лежит отдельной независимой строкой), преобразовать смещение в адрес, найти этот адрес, в коде, от этого адреса отмотать вверх до инструкции STMFD SP!, {..., LR}.
Сразу возникает вопрос — найти строку не проблема, а вот как преобразовать смещение в файле в виртуальный адрес? Не заниматься же разбором файла вручную. Тут на помощь приходит всемогущий гугль: есть пакет pyelftools. Так что «pip install pyelftools», и выкуриваем приложенную документацию. Там что-то ничего нет полезного. ОК, лезем тупо в файл elffily.py и смотрим что там есть вкусного. Находим там функцию, которая делает обратную задачу — находит смещение по виртуальному адресу:
def address_offsets(self, start, size=1):
""" Yield a file offset for each ELF segment containing a memory region.
A memory region is defined by the range [start...start+size). The
offset of the region is yielded.
"""
end = start + size
for seg in self.iter_segments():
if (start >= seg['p_vaddr'] and
end <= seg['p_vaddr'] + seg['p_filesz']):
yield start - seg['p_vaddr'] + seg['p_offset']
Всё понятно, легким движением руки превращаются данные брюки в элегантные шорты:
# удаляем f.close(), и на её месте пишем
from elftools.elf.elffile import ELFFile
f.seek(0, 0)
Elf = ELFFile(f)
def offToVA(offset):
for k in Elf.iter_segments():
if offset >= k['p_offset'] and offset <= k['p_offset']+k['p_filesz']:
return k['p_vaddr']+(offset-k['p_offset'])
Теперь мы можем найти строку, и место её использования:
s = "sendPacket"
## Find string itself
offStr = re.findall(s+"\x00", fw)
if len(offStr)==1:
offStr = re.search(s+"\x00", fw)
offStrVA = offToVA(offStr.start(0))
print "offStr["+s+"] =", hex(offStrVA)
elif len(offStr)==0:
print s, "string marker not found"
else:
print "Too many", s, "string markers found"
Тут я по лени использовал другой метод проверки ожиданий — просто сперва пытаюсь найти все, и считаю, что всё ок если она одна. Иначе сообщаю об ошибке. Тоже метод не удобный, + лишние поиски, зато мне лично понятнее, так что эксперименты с finditer повторять не буду.
Ну и теперь найдём смещение, где используется строка:
## Find offset to string
reStrLink = "\\x%02X\\x%02X\\x%02X\\x%02X" % (
(offStrVA)%256,
(offStrVA/256)%256,
(offStrVA/256/256)%256,
(offStrVA/256/256/256)%256 )
offLink = re.findall(reStrLink, fw)
if len(offLink)==1:
offLink = re.search(reStrLink, fw)
offLink = offLink.start(0)
if DEBUG: print "offLink["+s+"] = ", hex(offToVA(offLink))
return offLink
else:
print "Can't find usage of", s
Как вы уже догадались, вместо
s = "sendPacket"
на самом деле написано
def findStringLink(s):
, так как понадобится нам найти функцию не один раз.
Ну и теперь нам надо найти еще начало функции — прошагаем назад по 4 байта в поисках STMFD SP!, {..., LR}. Из-за лени, я ограничился поиском STMFD SP!, {...} (чтобы не анализировать биты). Причина — если найти не начало функции, дальше всё равно поиск поломается, о чем я узнаю, и тогда уже смогу решить как лучше чинить.
# Find previous function begin offset (nearest STMFD SP!, {...} instruction)
def findFuncBegin(offset, maxLen = 0x1000):
maxStart = max(0, offset-maxLen)
offset -= 4
while offset > maxStart:
if fw[offset+2:offset+4]=="\x2D\xE9":
return offset
offset -= 4
return None
Итак, у нас есть всё нужное, наконец-то найдём функцию sendPacket:
### 2. Find sendPacket function
sendPacketEnd = findStringLink("sendPacket")
sendPacketStart = findFuncBegin(sendPacketEnd)
if sendPacketStart is not None:
print "sendPacketStart = ", hex(offToVA(sendPacketStart))
else:
print "Can't find start of sendPacket"
Теперь мы приблизились к самому интересному — нам надо найти цикл внутри sendPacket, и убедиться что это он. Маску составляем уже как научились ранее:
sendLoopMask = """
?? 00 00 EA ; B loopBody
; ---------------------------------------------------------------------------
mm ;loopNext ; CODE XREF: SendPacket+74j
?? ?? ?? E5 ; LDR R4, [R4,#4] (or R5)
00 00 ?? E3 ; CMP R4, #0 (or R5)
?? 00 00 0A ; BEQ loc_6CA74
;loopBody ; CODE XREF: SendPacket+4Cj
mm ; ; SendPacket+D0nj
?? ?? A0 E1 ; MOV R3, R4 (or R5)
?? ?? A0 E1 ; MOV R1, R5
?? ?? A0 E1 ; MOV R2, R7
?? ?? A0 E1 ; MOV R0, R6
mm
?? ?? ?? EB ; BL SendRTPOverTCP
00 00 50 E3 ; CMP R0, #0
F5 FF FF AA ; BGE loopNext
"""
Но нам надо бы проверить, что BL внутри цикла — именно к SendRTPOverTCP, иначе это либо поменяли функцию сильно, или цикл уже был пропатчен нами, поэтому найдём еще и SendRTPOverTCP:
### 3. Find sendRTPOverTCP function
sendRTPOverTCPStart = findFuncBegin(findStringLink("sendRTPOverTCP"))
if sendRTPOverTCPStart is not None:
print "sendRTPOverTCPStart = ", hex(offToVA(sendRTPOverTCPStart))
else:
print "Can't find start of sendPacket"
Так, но мы должны уметь проверить туда ли ссылка. Все переходы в ARM не абсолютные, а относительные, плюс адреса задаются исчисляя +2 инструкции от текущей, да еще задаются в квантах инструкций (то есть делённая на 4). В общем, опкод рисуется
как-то так:
TargetAddr = Opcode*(4 bytes/word) + CurAddr + 8
В итоге, получаем такие функции, для вычисления адреса, куда ссылается некая инструкция, и для вычисления какой будет операнд инструкции по некому адресу для перехода куда надо:
def BinArg(off):
return ord(fw[off])+ord(fw[off+1])*256+ord(fw[off+2])*256*256
def ArgToBin(arg):
return chr(arg%256)+chr(arg/256%256)+chr(arg/256/256%256)
def cmdTargetOffset(cmdoff):
d1 = BinArg(cmdoff)
if d1 >= 0x800000: d1 -= 0x1000000
return (cmdoff+(d1+1)*4+4)
def cmdTargetArg(cmdoff, target):
d1 = (target - (cmdoff+4))/4 - 1
if d1 < 0: d1 += 0x1000000
return d1
Теперь, когда кирпичи отложены, построим очередную переборку:
### 4. find loop in sendPacket
sendPacketLoopRx = maskToRegex(sendLoopMask)
sendPacketLoop = re.findall(sendPacketLoopRx, fw, re.DOTALL)
if len(sendPacketLoop)==1:
sendPacketLoop = re.search(sendPacketLoopRx, fw, re.DOTALL)
sendPacketLoopNext = sendPacketLoop.start(1)
sendPacketLoopBL = sendPacketLoop.start(3)
sendPacketLoop = sendPacketLoop.start(2)
if DEBUG: print "sendPacket loop at ", hex(offToVA(sendPacketLoop))
elif len(sendPacketLoop)==0:
print "Loop inside sendPacket not found"
else:
print "Non-unqiue loops masks for sendPacket found"
## 4.1. check that loop link is really to sendRTPOverTCP
if cmdTargetOffset(sendPacketLoopBL) != sendRTPOverTCPStart:
print "Loop's first call is not sendRTPOverTCP"
if cmdTargetArg(sendPacketLoopBL, sendRTPOverTCPStart)!=BinArg(sendPacketLoopBL):
print "BUG! cmdTargetArg inconsistent with cmdTargetOffset!"
if ArgToBin(cmdTargetArg(sendPacketLoopBL, sendRTPOverTCPStart)) != fw[sendPacketLoopBL:sendPacketLoopBL+3]:
print "BUG! ArgToBin inconsistent with cmdTargetOffset!"
Итак, мы нашли цикл, проверили что BL в нём ссылается именно на sendRTPOverTCP, а заодно проверили функции адресов что они консистентны. Это всё позволяет защититься от опечаток и избавиться от лишних покрытий тестами функций — мы пишем скрипт, а не программный продукт, поэтому только необходимый максимум телодвижений.
Но для того, чтобы пропатчить, нам надо добавить 6 новых инструкций, а для этого нам надо место. Так как у нас в цикле есть два лишних принфа, как мы уже знаем, их-то мы и используем. Первый принтф 5 инструкций, значит, затирать придётся оба. Для этого их придётся найти и убедиться что это они:
### 5. Find next two printfs
printf1 = sendPacketLoopBL+4
while printf1 < sendPacketEnd:
if fw[printf1+3]=="\xEB":
printfStart = cmdTargetOffset(printf1)
break
printf1 += 4
printf1 += 4
printf2 = printf1 + 4
while printf2 < sendPacketEnd:
if fw[printf2+3]=="\xEB":
if cmdTargetOffset(printf2) != printfStart:
print "ERROR! After loop not two printfs!"
break
printf2 += 4
printf2 += 4
if (printf1-sendPacketLoop)/4-7 != 5:
print "WARN! First printf not 5 instructions"
if (printf1-sendPacketLoop)/4-7 > 5:
printf2 = printf1 # no need to cleanup 2nd printf
Опять же, идём простейшим путём — берём два ближайших BL после BL sendRTPOverTCP, проверяем что они оба ссылаются на одну функцию, и проверяем их размеры. Если что либо пойдёт не так — сразу ругнёмся. Если же всё соответствует ожиданиям — то всё хорошо.
Ну а теперь собрать всё это вместе, добавить дури три ведра, и сгенерировать патч:
### 6. Generate new loop body
PatchSendPacket = ""
## 6.1. LDR R0, Socket(Rx#8)
ldrSock = "\x08\x00"+fw[sendPacketLoopNext+2]+"\xE5"
PatchSendPacket += ldrSock
## 6.2. BL makeSocketBlocking
tgtSocketBlock = cmdTargetArg(sendPacketLoop+len(PatchSendPacket), makeBlock)
PatchSendPacket += ArgToBin(tgtSocketBlock)+"\xEB"
## 6.3. Copy 4 MOVs
PatchSendPacket += fw[sendPacketLoop:sendPacketLoopBL]
## 6.4. BL sendRTPOverTCP
tgtSendRTPOverTCP = cmdTargetArg(sendPacketLoop+len(PatchSendPacket), sendRTPOverTCPStart)
PatchSendPacket += ArgToBin(tgtSendRTPOverTCP)+"\xEB"
## 6.5. STMFD SP!, {R0}
PatchSendPacket += "\x01\x00\x2D\xE9"
## 6.6. LDR R0, Socket
PatchSendPacket += ldrSock
## 6.7. BL makeSocketNonBlocking
tgtSocketNonBlock = cmdTargetArg(sendPacketLoop+len(PatchSendPacket), makeNonBlock)
PatchSendPacket += ArgToBin(tgtSocketNonBlock)+"\xEB"
## 6.8. LDMFD SP!, {R0}
PatchSendPacket += "\x01\x00\xBD\xE8"
## 6.9. CMP R0, #0
PatchSendPacket += "\x00\x00\x50\xE3"
## 6.A. BGE loopNext
tgtLoopNext = cmdTargetArg(sendPacketLoop+len(PatchSendPacket), sendPacketLoopNext)
PatchSendPacket += ArgToBin(tgtLoopNext)+"\xAA"
## 6.B. Fill up to printf2 with NOPs
Nops = (printf2 - (sendPacketLoop + len(PatchSendPacket))) / 4
PatchSendPacket += "\x00\x00\xA0\xE1" * Nops
## 6.C. Save generated patch
patches = []
patches.append( (sendPacketLoop, PatchSendPacket) )
print "Successfully patched"
Теперь, когда у нас есть все патчи (пока аж целый один, но всё вперде), сгенерируем новый файл:
### FIN: save patched file
if True:
f = open(fname+".fixed", "w+b")
patches.sort()
last = 0
for p in patches:
f.write( fw[last:p[0]] )
f.write( p[1] )
last = p[0]+len(p[1])
f.write(fw[last:])
f.close()
Вот так, с помощью палки, верёвки, питона и грамма мозга мы легко и просто запатчили все 11 нужных мне стримера сразу, причем времени ушел один вечер, что примерно равно времени, которое ушло бы на то, чтобы вручную запатчить их все. Вот только при следующем обновлении, уже не потребуется тратить времени вообще!
for k in `(cd todo; ls -1)`; do g=$(echo $k | perl -pe 's/.*?([TV][^-]+).*?-V(2[^_]+).*/$1_$2/'); mv todo/$k .; ../repack/unpack.sh $k; cp $k.unpack/root/opt/topsee/rtsp_streamer rtsp_streamer_$g; done
for k in rtsp_streamer_*.[0-9]; do python tcpfix.py $k; done
...
p.s.: всем, кому что-то еще надо от этих прошивок, выложил скрипты в более удобное место —
на гитхаб.