Yet another P2P Messenger
Читать отзывы и документацию о языке не достаточно, чтобы научиться на нем писать более менее полезные приложения.
Обязательно для закрепления нужно создать что-то интересное, чтобы наработки можно было бы использовать в других своих задачах.
Статья ориентирована на новичков интересующихся языком go и пиринговыми сетями.
И для профессионалов, умеющих предлагать разумные идеи или конструктивно критиковать.
Программирую достаточно давно с разной степенью погруженности на java, php, js, python.
И каждый язык программирования хорош в своей сфере.
Основной сферой для Go называют создание распределенных сервисов, микросервисов.
Чаще всего микросервис это небольшая программа, выполняющая свой узкоспециализированный функционал.
Но микросервисы должны ещё уметь общаться друг с другом, поэтому инструмент для создания микросервисов должен позволять легко и без боли организовывать сетевое взаимодействие.
Чтобы проверить это напишем приложение организовывающее децентрализованную сеть равноправных участников (Peer-To-Peer), самое простое — p2p мессерджер (кстати, есть ли русский синоним этому слову?).
В коде активно изобретаю велосипеды и наступаю на грабли, чтобы прочувствовать golang, получить конструктивную критику и рациональные предложения.
Что делаем
Пир (peer) — уникальный экземпляр мессенджера.
Наш мессенджер должен уметь:
- Находить соседние пиры
- Устанавливать соединение с другими пирами
- Шифровать обмен данными с пирами
- Принимать сообщения от пользователя
- Показывать сообщения пользователю
Чтобы задачку сделать чуть интереснее, давайте сделаем так, чтобы все это проходило через один сетевой порт.
Если дернуть этот порт по HTTP, то получим реактовское приложение, которое дернет этот же порт, установив web socket соединение.
Если дергать порт по HTTP не с локальной машины, то показываем баннер.
Если к этому порту подключается другой пир, то происходит установка постоянного соединения со сквозным (end-to-end) шифрованием.
Определяем тип входящего соединения
Для начала откроем порт для прослушивания и будем ждать новых соединений.
net.ListenTCP("tcp", tcpAddr)
На новое соединение читаем первые 4 байта.
Берем список глаголов HTTP и сравниваем с ним наши 4 байта.
Теперь определяем с локальной ли машины происходит подключение, и если нет, то отвечаем баннером и "вешаем трубку".
buf, err := readWriter.Peek(4)
/* обработка ошибки */
if ItIsHttp(buf) {
handleHttp(readWriter, conn, p)
} else {
peer := proto.NewPeer(conn)
p.HandleProto(readWriter, peer)
}
/* ... */
if !strings.EqualFold(s, "127") && !strings.EqualFold(s, "[::") {
response.Body = ioutil.NopCloser(strings.NewReader("Peer To Peer Messenger. see https://github.com/easmith/p2p-messenger"))
}
Если же подключение локальное, то отвечаем файлом, соответствующим запросу.
Тут я решил написать обработку самостоятельно, хотя можно было бы воспользоваться имеющимся в стандартной библиотеке обработчиком.
// свой способ
func processRequest(request *http.Request, response *http.Response) {/* много строчек кода */}
// либо из страндартной библиотеки
fileServer := http.FileServer(http.Dir("./front/build/"))
fileServer.ServeHTTP(NewMyWriter(conn), request)
Если же запрашивается путь /ws
, то пробуем установить websocket соединение.
Раз уж я собрал велосипед в обработке запросов файлов, то обработку ws соединения сделаю с помощью библиотеки gorilla/websocket.
Для этого создадим MyWriter
и реализуем в нем методы для соответствия интерфейсам http.ResponseWriter
и http.Hijacker
.
// w - MyWriter
func handleWs(w http.ResponseWriter, r *http.Request, p *proto.Proto) {
c, err := upgrader.Upgrade(w, r, w.Header())
/* теперь работаем с соединением почти как с обычным сокетом */
}
Обнаружение пиров
Для поиска пиров в локальной сети воспользуемся мультикастом UDP.
Будем отправлять на Multicast IP адрес пакеты с информацией о нас самих.
func startMeow(address string, p *proto.Proto) {
conn, err := net.DialUDP("udp", nil, addr)
/* ... */
for {
_, err := conn.Write([]byte(fmt.Sprintf("meow:%v:%v", hex.EncodeToString(p.PubKey), p.Port)))
/* ... */
time.Sleep(1 * time.Second)
}
}
И отдельно прослушивать от Multicast IP все UDP пакеты.
func listenMeow(address string, p *proto.Proto, handler func(p *proto.Proto, peerAddress string)) {
/* ... */
conn, err := net.ListenMulticastUDP("udp", nil, addr)
/* ... */
_, src, err := conn.ReadFromUDP(buffer)
/* ... */
// connectToPeer
handler(p, peerAddress)
}
Таким образом мы заявляем о себе и узнаем о появлении других пиров.
Можно было бы организовать это на уровне IP и даже в официальной документации пакета IPv4 в качестве примера кода приводится как раз multicast пакета данных.
Протокол взаимодействия пиров
Будем все общение между пирами упаковывать в конверт (Envelope).
На любом конверте всегда есть отправитель и получатель, к этому всему мы добавим команду (которую он с собой несет), идентификатор (пока это случайное число, но можно сделать как хэш содержимого), длина содержимого и само содержимое конверта — сообщение или параметры команды.
Команда, (или же тип содержимого) удачно расположим в самом начале конверта и определим список команд из 4 байт, не пересекающихся с именами глаголов HTTP.
Весь конверт при передаче сериализуется в массив байт.
Рукопожатие
Когда соединение установлено, пир тут же протягивает руку для рукопожатия, сообщая свое имя, публичный ключ и эфемерный публичный ключ для генерации общего сессионного ключа.
В ответ пир получает аналогичный набор данных, регистрирует найденный пир в своем списке и вычисляет (CalcSharedSecret) общий сессионный ключ.
func handShake(p *proto.Proto, conn net.Conn) *proto.Peer {
/* ... */
peer := proto.NewPeer(conn)
/* Отправляем свое имя и ключ*/
p.SendName(peer)
/* Ждем имя и ключ */
envelope, err := proto.ReadEnvelope(bufio.NewReader(conn))
/* ... */
}
Обмен пирами
После рукопожатия, пиры обмениваются своими списками пиров =)
Для этого отправляется конверт с командой LIST, а в его содержимое кладется JSON список пиров.
В ответ получаем аналогичный конверт.
Находим в списках новых и с каждым из них проделываем попытку соединения, рукопожатия, обмена пирами и так далее…
Обмен пользовательскими сообщениями
Пользовательские сообщения представляют для нас наибольшую ценность, поэтому каждое соединение будем шифровать и подписывать.
О шифровании
В стандартных (гугловых) библиотеках golang из пакета crypto реализовано множество всяких разных алгоритмов (ГОСТовских нет).
Наиболее удобной для подписей считаю кривую Ed25519. Будем использовать библиотеку ed25519 для подписи сообщений.
В самом начале я подумывал использовать пару ключей полученных из ed25519 не только для подписи, но и для генерации сессионного ключа.
Однако, ключи для подписи применимы для вычисления общего (shared) ключа — над ними еще нужно поколдовать.
Поэтому решено генерить эфемерные ключи, и вообще говоря, это правильный подход не оставляющий злоумышленникам шансов подобрать общий ключ.
Для любителей математики вот ссылки на wiki:
ПротоколДиффи—_Хеллмана_на_эллиптических_кривых
Цифровая подпись EdDSA
Генерация общего ключа вполне стандартная: сначала для нового соединения генерим эфемерные ключи, отправляем в сокет конверт с публичным ключом.
Противоположная сторона делает то же самое но в другом порядке: получает конверт с публичным ключом, генерит свою пару и отправляет публичный ключ в сокет.
Теперь у каждого участника есть чужой публичный и свой приватный эфемерные ключи.
Перемножив их получаем одинаковый для обоих ключ, который и будем использовать для шифрования сообщений.
//CreateKeyExchangePair create pair for ECDHE
func CreateKeyExchangePair() (publicKey [32]byte, privateKey [32]byte) {
pub, priv, err := ed25519.GenerateKey(nil)
/* обработка ошибки */
copy(publicKey[:], pub[:])
copy(privateKey[:], priv[:])
curve25519.ScalarBaseMult(&publicKey, &privateKey)
return
}
Шифровать сообщения будем поштучно давно зарекомендовавшим себя алгоритмом AES в режиме сцепления блоков (CBC).
Вся эта реализации легко находятся в документации golang.
Единственная доработка — авто заполнение сообщения нулевыми байтами для кратности его длины к длине блока шифрования (16 байт).
//Encrypt the message
func Encrypt(content []byte, key []byte) []byte {
padding := len(content) % aes.BlockSize
if padding != 0 {
repeat := bytes.Repeat([]byte("x00"), aes.BlockSize-(padding))
content = append(content, repeat...)
}
/* ... */
}
//Decrypt encrypted message
func Decrypt(encrypted []byte, key []byte) []byte {
/* ... */
encrypted = bytes.Trim(encrypted, string([]byte("x00")))
return encrypted
}
В далеком 2013 году реализовывал AES (с похожим на CBC режимом) для шифрования сообщений в Telegram в рамках конкурса от Павла Дурова.
Для генерации эфемерного ключа в то время в телеграмм использовался самый обычный протокол Диффи — Хеллмана (https://ru.wikipedia.org/wiki/Протокол_Диффи_—_Хеллмана).
А чтобы исключить нагрузку от фейковых подключений перед каждым обменом ключами клиенты решали задачу факторизации.
GUI
Нам нужно показать список пиров и список сообщений с ними, а также реагировать на новые сообщения увеличивая счетчик рядом с именем пира.
Тут без заморочек — ReactJS + websocket.
Веб-сокет сообщения по сути своеобразные конвертики, только они не содержат в себе шифротекстов.
Все они "наследники" типа WsCmd
и при передаче сериализуются в JSON.
//Serializable interface to detect that can to serialised to json
type Serializable interface {
ToJson() []byte
}
func toJson(v interface{}) []byte {
json, err := json.Marshal(v)
/* обработка err */
return json
}
/* ... */
//WsCmd WebSocket command
type WsCmd struct {
Cmd string `json:"cmd"`
}
//WsMessage WebSocket command: new Message
type WsMessage struct {
WsCmd
From string `json:"from"`
To string `json:"to"`
Content string `json:"content"`
}
//ToJson convert to JSON bytes
func (v WsMessage) ToJson() []byte {
return toJson(v)
}
/* ... */
Итак, приходит HTTP запрос на корень ("/"), теперь чтобы отобразить фронт заглядываем в каталог “front/build” и отдаем index.html
Что ж интерфейс сверстан, теперь выбор для пользователей: запускать его в браузере или в отдельном окошке — WebView.
Для последнего варианта использовал zserge/webview
e := webview.Open("Peer To Peer Messenger", fmt.Sprintf("http://localhost:%v", initParams.Port), 800, 600, false)
Для сборки приложения с ним нужно установить еще либу в систему
sudo apt install libwebkit2gtk-4.0-dev
В ходе раздумий над GUI нашел множество библиотек для GTK, QT, и очень по гиковски смотрелся бы консольный интерфейс — https://github.com/jroimartin/gocui — по-моему очень даже интересная идея.
Запуск мессенджера
Установка golang
Конечно, сначала нужно установить go.
Для этого настоятельно рекомендую воспользоваться инструкцией golang.org/doc/install.
Упростил инструкцию до bash скрипта
Загрузка приложения в GOPATH
Так уж устроен go, что все библиотеки и даже ваши проекты должны лежать в так называемом GOPATH.
По-умолчанию это $HOME/go. Go позволяет стянуть исходники из публичного репозитория простой командой:
go get github.com/easmith/p2p-messenger
Теперь в вашем каталоге $HOME/go/src/github.com/easmith/p2p-messenger
появится исходник из ветки master
Установка npm и сборка фронта
Как писал выше, наш GUI — веб-приложение с фронтом на ReactJs, поэтому фронт ещё нужно собрать.
Nodejs + npm — тут как обычно.
На всякий случай вот инструкция для убунту
Теперь стандартно запускаем сборку фронта
cd front
npm update
npm run build
Фронт готов!
Запуск
Перейдем обратно в корень и запустим пир нашего мессенджера.
При запуске можем указать имя своего пира, порт, файл с адресами других пиров и флаг указывающий запускать ли WebView.
По-умолчанию используется $USER@$HOSTNAME
в качестве имени пира и порт 35035.
Итак, запускаем и чатимся с друзьями по локальной сети.
go run app.go -name Snowden
Отзыв о программировании на golang
- Самое важное что хотелось бы отметить: на go сразу получается реализовать то, что задумал.
Почти все необходимое есть в стандартной библиотеке. - Однако, была и сложность, когда я начал проект в отличном от GOPATH каталоге.
Для написания кода использовал GoLand. И поначалу смущало автоматическое форматирование кода с автоимпортом библиотек. - В IDE много кодогенераторов, что позволяло сосредоточится на разработке, а не на наборе кода.
- К частой обработке ошибок быстро привыкаешь, но случается рука-лицо, когда понимаешь что для go нормальная ситуация, когда суть ошибки анализируется по ее строковому представлению.
err != io.EOF
- Чуть лучше дело обстоят с библиотекой os. Там понять суть проблемы помогают такие конструкции
if os.IsNotExist(err) { /* ... */ }
- Из коробки go учит нас правильно документировать код и писать тесты.
И тут есть свои но. Мы описали интерфейс с методомToJson()
.
Так вот, генератор документации не наследует описание этого метода на методы его реализующие, поэтому чтобы убрать лишние варниги, приходится копировать документацию в каждый реализованный метод (proto/mtypes.go). - В последнее время привык к мощи log4j в java, поэтому не хватает хорошего логгера в go.
Наверное, стоит поискать на просторах гитхаба красивое логгирование с аппендерами и форматерами. - Непривычна работа с массивами.
Например, конкатенация происходит через функциюappend
, а преобразование массива произвольной длины в массив фиксированной длины черезcopy
. switch-case
работает какif-elseif-else
— а вот это интересный подход, но опять рука-лицо:
если хотим привычное поведениеswitch-case
, нужно у каждого кейса проставлятьfallthrough
.
А еще можно использоватьgoto
, но давайте не будем, пожалуйста!- Нет тернарного оператора и часто это не удобно.
Что дальше?
Вот и реализован простейший Peer-To-Peer мессенджер.
Набиты шишки, дальше можно улучшать пользовательский функционал: отправка файлов, картинок, аудио, смайлов и т.д и т.п.
А можно не изобретать свой протокол, а использовать гугловый Protocol Buffers,
подключить блокчейн и защититься от спама с помощью смарт-контрактов Ethereum.
На смарт-контрактах же организовать групповые чаты, каналы, аватарки и профили пользователей.
Еще обязательно запустить seed пиры, реализовать обход NAT и передачу сообщений от пира к пиру.
В итоге получится неплохая замена телеграмма/вотсапа, останется только всех друзей туда пересадить =)
Полезности
В ходе работы над мессенджером нашел интересные для начинающего go разработчика страницы.
Делюсь ими с вами:
golang.org/doc/ — документация по языку, все просто, понятно и с примерами. Эту же документацию можно запустить локально командой
godoc -HTTP=:6060
gobyexample.com — сборник простых примеров
golang-book.ru — хорошая книга на русском
github.com/dariubs/GoBooks — сборник книг о Go.
awesome-go.com — список интересных библиотек, фреймворков и приложений на go. Категоризация более менее, а вот описание многих из них очень скудная, что не помогает поиску по Ctrl+F
Автор: Евгений Кузнецов