commit 0f1177bd91284a43a82373dc7e1085696c9162f9 Author: eleksir Date: Sun Jun 11 21:03:41 2023 +0300 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4d6aa15 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.idea/** +.vscode/** +.vstags +*.bak +*.tmp +**/*.bak +**/*.tmp +data/config.json +data/whitelist.json +data/blacklist.json +buny-jabber-bot diff --git a/Design.txt b/Design.txt new file mode 100644 index 0000000..55a54fe --- /dev/null +++ b/Design.txt @@ -0,0 +1,51 @@ +Oсобенности реализации клиентской части. + +Общие положения +------------------------------------------------------------------------------------------------------------------------ + +Nick для room-ов един и не может задаваться на per-conference основе. На этом завязаны отсылка ping-ов и определение +pong-ов, как минимум. Хотя формально джойниться можно с любым ником :) но что-то не хочется потом распутывать этот +клубок. + + +XEP-0410 MUC Self-Ping (Schrödinger's Chat) +------------------------------------------------------------------------------------------------------------------------ + +xep-0410 реализован не польностью. Согласно https://xmpp.org/extensions/xep-0410.html, существует минимум 2 варианта +реализации обнаружения, что киент оотвалился от чатика. + +* С двухступенчатым пингом. Когда с jid-а локального пнгуется jid в конференции. Ответ доставляется на один из клиентов. +Который в свою очередь должен ответить на этот ответ (отвечайте на мои ответы!), результат этого ответа передаётся +оригинальному запросившему клиенту. + +* С серверной орптимизацией. (оптимизация анонсируется в ответе на запрос disco#info MUC-а) +В этом случае пинг будет одноступенчатым и нам сразу же должен по идее вернуться result (то бишь pong) + +Двухступенчатая реализация вызывает вопросы. Во-первых, своей двухступенчатастью, а во-вторых, ответ на начальный пинг +доставляется на один из клиентов (не на все!), а если этот клиент испытывает сетевые проблемы, то получается, страдают +все клиенты. В нашем случае предполагается, что если клиентов больше одного, то он не должен своим состоянием аффектить +других клиентов. + +И... тут есть другой момент - двухступенчатый пинг гораздо сложнее в реализации на клиентской стороне, т.к. надо +обрабатывать значительно бОльшее количество кейсов сочетаний ошибок, которые могут возникнуть в ходе пинга. + +Вобщем, поддерживаем только второй вариант пинга, который с серверной оптимизацией. + +N.B. Но на IQ get ping с xmlns="urn:xmpp:ping" из первого шага мы всё-таки будем отвечать обычным result-ом (понгом, то + бишь) + +N.B. Каждый пинг комнаты происходит в отдельной goroutne-ке, так что заходить в огромное количеств комнат (>1k) + не рекомендуется + + +xep-0199 XMPP Ping +------------------------------------------------------------------------------------------------------------------------ + +Cуществует несколько видов пинга: +* Server-To-Client Pings +* Client-To-Server Pings +* Server-To-Server Pings +* Client-to-Client Pings +* Component-to-Client Pings + +Явным образом я реализовываю с2s. Плюс частично Component-to-Client в рамках N.B. по xep-0410. diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..13255ce --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,20 @@ +Copyright (c) 2022 Sergei Fedosoff + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..175118e --- /dev/null +++ b/Makefile @@ -0,0 +1,21 @@ +#!/usr/bin/env gmake -f + +GOOPTS=CGO_ENABLED=0 +BUILDOPTS=-ldflags="-s -w" -a -gcflags=all=-l -trimpath + +all: clean build + +build: + ${GOOPTS} go build ${BUILDOPTS} -o "buny-jabber-bot" collection.go types.go globals.go lib.go event_parser.go \ + buny.go main.go + +clean: + go clean + +upgrade: + rm -rf vendor + go get -d -u -t ./... + go mod tidy + go mod vendor + +# vim: set ft=make noet ai ts=4 sw=4 sts=4: diff --git a/README.md b/README.md new file mode 100644 index 0000000..ff9d897 --- /dev/null +++ b/README.md @@ -0,0 +1,93 @@ +# Buny Jabber Bot + +Простой бот, работающий по протоколу xmpp. Его задача - банить абьюзеров чата. + +## Что он может? + +Имеет возможность соединяться с сервером по протоколу xmpp как с шифрованием, так и без. Шифрование работает по +классическим механикам (шифрованный канал связи, без возможности коммуникации в незашифрованном виде), без поддержки +механизма start tls. Что-то в гошке или на стороне сервера при выборе StartTLS не работает. + +Имеет возможность заносить пользователей в бан-лист согласно заданным в конфиге правилам. + +## Что он не может? + +* Не может заходить в комнаты, защищённые паролем. +* Не умеет разгадывать капчу. + +## Как заставить его работать? + +Этот бот автоматизирует уборку человеческой грязи. Самый важный вопрос, кторый следует себе задать еред его +использоованием - а не забаню ли я кого-то не того? + +Технически, бот собирается гошкой 1.20 поод платформу linux. Работоспособность на других платформах не тестировалась. + +Чтобы получить готовые бинарники достаточно выполнить команду: + +```bash +make +``` + +в итоге получится бинарник **buny-jabber-bot**, которому нужен конфиг data/config.json для работы. + +А также понадобятся **data/blacklist.json** и **data/whitelist.json** в которых списки правил, согласно которым *точно* +надо банить злодеев и списки пользователей, которых *точно* не надо банить. + +### Замечание + +Очевидным образом в комнате банить может только админ. Поэтому боту придётся выдать админские права. А комнату сделать +не анонимной (то есть, чтобы как минимум админы могли видеть реальный jid, потому что в бан отправляется именно он). + +## Как работает бан? + +Никакой магии, всё по стандарту, согласно [xep-0045](https://xmpp.org/extensions/xep-0045.html#ban). + +Серверу направляется команда изменения участия (affiliation) зашедшего jid-а и задаётся значение outcast, на что сервер +помещает этот jid в список изгнанных из комнаты (бан-лист конкретной комнаты, в которой присутствует бот) и выгоняет +этого jid-а. + +### В чём тонкость? + +Ровно в том, что для исполнения действия, боту надо заметить presence участника, а presence посылается +при изменении состояния клиента. То есть, если jid уже был в комнате на момент захода бота в неё, то его не забанят +(во всяком случае моментально). Самый простой способ поместить таких персонажей в бан - просто кикнуть их. Ихний +affiliation сменится на none, бот заметит presence и, если jid подпадает под правила чёрного списка, то отправит этот +jid в бан. + +Но, простите, ведь "по правилам", при заходе клиента в комнату ему присылается presence всех участников, чтобы он мог +построить локальный ростер комнаты и начать отслеживать изменения состояния участников конференции. Почему бы в этот +момент не банить злодеев? + +Да, так делать можно, однако пока этого нет. Дело в том, что presence самого себя участнику приходит последним (после +presence-ов тех, кто находится в комнате на момент появления в ней бота), и, согласно этому событию, можно считать, что +клиент зашёл в комнату успешно и формально *только после этого*, клиент может что-то пытаться делать в этой комнате. +Соответственно, чтобы кого-то банить исходя из списка presense-ов, который прилетает на входе, необходимо реализовать +"одноразовую" очередь тех, кого имеет смысл забанить и разгрести её "когда будет можно". Эта задача факультативная, +возможно, она будет решена потом. + +## Как работает белый список? + +Белый список - это список jid-ов, которых банить нельзя ни при каких обстоятельствах. В нём всегда неявно присутсвует +сам бот. + +### Можно ли почистить ban-list? + +Да, бывают ситуации, когда по ошибке бот может забанить кого-то не того или, если не настроен белый список вообще ни в +каком виде, то будет банить всех входящих. И если вдруг произошло так, что в бан попали все админы комнаты, то разбанить +их может либо овнер (никто же не даст овнера боту), либо тот, кто зайдёт в комнату из-под аккаунта бота и разбанить всех +от его имени. + +В далёких планах есть идея релизовать очистку бан-листа при запуске бота с настройкой `"wipe_bans": true`` в +whitelist.json для канала; кроме того, есть планы такое реализовать и в качестве команды, когда у бота появятся команды. + +## Дисклеймер + +Весь дисклеймер в файле LICENSE.txt :) + +## Благодарности + +Во-первых `сообществу`, без него не было бы чятиков. + +Во-вторых `batman46(new)` из конференций на `jabber.ru`, за наблюдения и за некоторые идеи, воплощённыые в этом боте. + +В-третьих `U2` из конференции `radio@conference.jabber.ru` за мысли, идеи и за позитивный настрой. diff --git a/Story.txt b/Story.txt new file mode 100644 index 0000000..535c803 --- /dev/null +++ b/Story.txt @@ -0,0 +1,16 @@ +Конечно, очень печально, что приходится развивать свои навыки программизмов на программах с такой задачей. Но жизнь не +всегда радует своими проявлениями. + +История бота довольно банальна: к нам в чятик стал набергать один товарищ и нести чушь про политоту. Если его банить +классическими средствами, он просто создавал новый аккаунт и снова набегал гадить. + +И таким образом родилась идея как-то автоматизировать его бан. + +Поскольку сама идея достойна мема, то боту было дано имя buny. Как отсылка к комикс-стрипу buni, к персонажу +banhammer-тян и как отсылка к персонажу по имени Бани из сериала про Сейлор Мун (если верить Википедии, то Бани на самом +деле это Usagi Tsukino, но у русских переводчиков своё видение мира). + +Jid-ом бота стал buny@jabber.ru, а девизом - надпись из школьного туалета (исковерканный девиз из Сейлор Мун): +"Облегчающая сила Луны, помоги мне!". + +С портретом, традиционно, заминка. diff --git a/buny.go b/buny.go new file mode 100644 index 0000000..2ca627d --- /dev/null +++ b/buny.go @@ -0,0 +1,143 @@ +package main + +import ( + "os" + "regexp" + "strings" + + "github.com/davecgh/go-spew/spew" + "github.com/eleksir/go-xmpp" + log "github.com/sirupsen/logrus" +) + +func buny(v xmpp.Presence) error { + var err error + + // Если у presence-а есть JID и presence из одной из комнат, в которой мы есть и если его домен в чёрном + // списке, заносим пидора в список outcast-ов + if v.JID != "" { + // На всякий случай: себя никогда не баним, явным образом + if v.JID == talk.JID() { + return err + } + + room := strings.SplitN(v.From, "/", 2)[0] + + if room == "" { + log.Infof("We got empty room in repsence event, which kinda strange: %s", spew.Sdump(v)) + + return err + } + + goodJid := strings.SplitN(v.JID, "/", 2)[0] + + // Обрабатываем правила белого списка + for _, good := range whiteList.Whitelist { + // Глобальный белый список + if good.RoomName == "" { + for _, person := range good.Jid { + if person == goodJid { + return err + } + } + } + + // Белый список для конкретных каналов + if good.RoomName == room { + for _, person := range good.Jid { + if person == goodJid { + return err + } + } + } + } + + evilJid := strings.SplitN(v.JID, "/", 2)[0] + + // Обрабатываем правила чёрного списка + for _, cRoom := range roomsConnected { + if cRoom == room { + for _, bEntry := range blackList.Blacklist { + + // Обработаем правила гобального чёрного списка + if bEntry.RoomName == "" { + for _, jid_regexp := range bEntry.JidRe { + re, err := regexp.Compile(jid_regexp) + + if err != nil { + log.Errorf("Incorrect regexp in blacklist: %s, skipping", jid_regexp) + + continue + } + + if re.Match([]byte(evilJid)) { + log.Infof("Hammer falls on %s", v.JID) + // https://xmpp.org/extensions/xep-0045.html#ban баним вот таким сообщением + ban := "" + ban += "" + ban += "" + + if id, err := talk.RawInformationQuery( + talk.JID(), + room, + "ban1", + xmpp.IQTypeSet, + "http://jabber.org/protocol/muc#admin", + ban, + ); err != nil { + log.Errorf("Unable to ban user: id=%s, err=%s", id, err) + os.Exit(1) + } + + return err + } + + } + + continue + } + + // Обработаем правила конкретного канала, комнаты конференции + if bEntry.RoomName == room { + for _, jid_regexp := range bEntry.JidRe { + re, err := regexp.Compile(jid_regexp) + + if err != nil { + log.Errorf("Incorrect regexp in blacklist: %s, skipping", jid_regexp) + + continue + } + + if re.Match([]byte(evilJid)) { + log.Infof("Hammer falls on %s", v.JID) + // https://xmpp.org/extensions/xep-0045.html#ban баним вот таким сообщением + ban := "" + ban += "" + ban += "" + + if id, err := talk.RawInformationQuery( + talk.JID(), + room, + "ban1", + xmpp.IQTypeSet, + "http://jabber.org/protocol/muc#admin", + ban, + ); err != nil { + log.Errorf("Unable to ban user: id=%s, err=%s", id, err) + os.Exit(1) + } + + return err + } + + } + + continue + } + } + } + } + } + + return err +} diff --git a/collection.go b/collection.go new file mode 100644 index 0000000..9805c97 --- /dev/null +++ b/collection.go @@ -0,0 +1,69 @@ +package main + +import ( + "sync" +) + +// Collection это sync.Map-ка, хранящая в себе произвольные итемы +type Collection struct { + items sync.Map + close chan struct{} +} + +// Item представляет собой интерфейс для хранения произвольных данных +type item struct { + data interface{} +} + +// NewCollection создаёт инстанс коллекции +func NewCollection() *Collection { + c := &Collection{ + close: make(chan struct{}), + } + + return c +} + +// Get достаёт данные по заданному ключу из коллекции +func (collection *Collection) Get(key interface{}) (interface{}, bool) { + obj, exists := collection.items.Load(key) + + if !exists { + return nil, false + } + + item := obj.(item) + + return item.data, true +} + +// Set сохраняет данные с заданным ключом в коллекцию +func (collection *Collection) Set(key interface{}, value interface{}) { + collection.items.Store(key, item{ + data: value, + }) +} + +// Range применяет функцию f ко всем ключам в коллекции +func (collection *Collection) Range(f func(key, value interface{}) bool) { + fn := func(key, value interface{}) bool { + item := value.(item) + + return f(key, item.data) + } + + collection.items.Range(fn) +} + +// Delete удаляет ключ и значение из коллекции данных +func (collection *Collection) Delete(key interface{}) { + collection.items.Delete(key) +} + +// Close очищает и высвобождает ресурсы, занятые коллекцией +func (collection *Collection) Close() { + collection.close <- struct{}{} + collection.items = sync.Map{} +} + +/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */ diff --git a/data/sample_blacklist.json b/data/sample_blacklist.json new file mode 100644 index 0000000..c43eb4a --- /dev/null +++ b/data/sample_blacklist.json @@ -0,0 +1,15 @@ +{ + "blacklist": [ + { + # Пустой список означет глобальные баны, которые применяются ко всем комнатам, где присутствует бот + "room_name": "", + # Список регулярок JID-ов, которых надо банить + "jid_re": [ + "^[Mm]ary@server.tld/resource1$", + "^[cC]hris(topher)?@", + "^(his|her)_cat@sweet.home/.*$", + "^(her|his)_dog@.*\\.street/.*" + ] + } + ] +} \ No newline at end of file diff --git a/data/sample_config.json b/data/sample_config.json new file mode 100644 index 0000000..90048cb --- /dev/null +++ b/data/sample_config.json @@ -0,0 +1,56 @@ +{ + # Фронт-энд бота + "jabber": { + # Если не задан, будет localhost + "server": "jabber.tld", + # Если не задано - 5222 для нешифрованного соединения или для starttls и 5223 для голого (старорежимного) ssl + "port": 5222, + # Включить шифрование + "ssl": true, + # Это более современный протокол и как раз на нём работет большинство jabber-серверов, порт 5222, + # jabber.ru по этому протоколу как-то странно работает, если его включить, я получаю + # tls: first record does not look like a TLS handshake + "starttls" : true, + # Не проверяем сертификаты по-умолчанию. + # Вам правда хочется всё это проверять? Там по идее могут быть всякие Lets Encrypt, которые хоть и работают, но + # создают кучу проблем + "ssl_verify": false, + # Позволять авторизацию логин-паролем по незашифрованному каналу связи + "insecureallowunencryptedauth": false, + # Таймаут установления соединения с сервером, в секундах. 10 секунд, если не задано. + "connection_timeout": 10, + # Задержка перед пере-коннекчиванием. 3 секунды, если не задано. + "reconnect_delay": 3, + # Промежуток между пингами сервера, для определения его живости. По-умолчанию 60 секунд. + # Пинги посылаются, если в течение этого времени не было активности со стороны сервера. + "server_ping_delay": 60, + # Промежуток между пингами muc-ов. По-умолчанию 900 секунд. (Согласно рекомендации в xep-0410.) + # Пинги посылаются, если в течение этого времени не было активности со стороны muc-а. + "muc_ping_delay": 900, + # Сколько ждать перед переджоиванием к комнате. По-умолчанию, 3 секунды. + "muc_rejoin_delay": 3, + # Ping-to-ping splay delay. Варьируемая задержка между пингами, чтобы не набирать высокий message rate. + # По-умолчанию, 3 секунды. Под капотом преобразуются в миллисекунды. Важна для пингов MUC-ов, особенно, + # если их МНОГО. + "ping_splay_delay": 3, + # Ник бота, с которым он будет заходить в комнаты + "nick": "buny", + "resource" : "buny bot", + # Имя пользователя для авторизации на сервере + "user": "buny@jabber.tld", + # Пароль для авторизации + "password": "secret", + # Jabber-каналы, к которым бот попробует присоединиться при старте + "channels": [ + "my_channel@conference.jabber.tld", + "another_channel@conference.jabber.tld" + ] + }, + + # Многословность логов. Если не задано, то info. Debug - ДЕЙСТВИТЕЛЬНО вербозный уровень логгирования. + # Есть ещё trace :) + "loglevel": "debug", + + # Если не задан, логи идут в STDOUT + "log": "/var/log/echo-jabber-bot/echo-jabber-bot.log", +} diff --git a/data/sample_whitelist.json b/data/sample_whitelist.json new file mode 100644 index 0000000..a8cfd08 --- /dev/null +++ b/data/sample_whitelist.json @@ -0,0 +1,24 @@ +# Пример белого списка +{ + "whitlist": [ + { + # Если room_name пустой или его нет, это список jid-ов, которых мы не баним ни на одном канале, нигде и никогда + "room_name": "", + # Собственно, сам список + "jid": [ + "me@jabber.tld", + "myself@xmpp.tld", + "iren@example.org" + ] + }, + { + # А этот список относится к конференции earth@conference.jabber.tld + "room_name": "earth@conference.jabber.tld", + "jid": [ + "meow@street.net", + "bzzz@hive.org", + "god@heaven.org" + ] + } + ] +} diff --git a/event_parser.go b/event_parser.go new file mode 100644 index 0000000..0544928 --- /dev/null +++ b/event_parser.go @@ -0,0 +1,567 @@ +package main + +import ( + "encoding/xml" + "fmt" + "os" + "strings" + "time" + + "github.com/davecgh/go-spew/spew" + "github.com/eleksir/go-xmpp" + log "github.com/sirupsen/logrus" + "golang.org/x/exp/slices" +) + +func parseEvent(e interface{}) { + lastServerActivity = time.Now().Unix() + + switch v := e.(type) { + // Сообщение в чяти + case xmpp.Chat: + log.Debugf("Looks like message, ChatType: %s, From: %s, Subject: %s Text: %s", + v.Type, v.Remote, v.Subject, v.Text) + + if config.NoEcho { + return + } + + // Топик чятика присылается в виде сообщения с subject, но без text + // В то же время сообщения от людей приходят с пустым subject, но с заполненным text + if v.Text != "" { + // Чятики бывают групповые и не групповые, от этого зависит Remote, куда направлять сообщение + switch v.Type { //nolint:whitespace + + // Групповой чятик + case "groupchat": + log.Debugf("Message from public chat: %s", v.Text) + // dest := strings.SplitN(v.Remote, "/", 2)[0] + nick := strings.SplitN(v.Remote, "/", 2)[1] + + if nick == config.Jabber.Nick { + log.Debug("Skipping message from myself") + + return + } + + lastActivity = lastServerActivity + + if muc, _ := strings.CutSuffix(v.Remote, "/"); muc != "" { + lastMucActivity.Set(muc, lastServerActivity) + } + + // Приватный чятик + case "chat": + // Здесь у нас может быть 2 вида From: + // slackware-current@conference.jabber.ru/eleksir - это если сообщение из публичного чятика + // eleksir@jabber.ru/array.lan - это если мы работаем через ростер + log.Debugf("Private message: %s", v.Text) + + lastActivity = lastServerActivity + + // Внезапно, ошибки. По идее они должны ассоциироваться с отправляемыми сообщениями, но по ходу это + // не реализовано, поэтому мы получаем ошибки в форме отдельного чятика + case "error": + // Это ошибка со стороны сервера, видимо, в ответ на наши действия и поскольку такие вещи происходят + // асинхронно, связать их с конкретными нашими действиями довольно сложно. + log.Warn(spew.Sdump(e)) + } + } + + // IQ-сообщение - пинг, понг, что-то ещё... + case xmpp.IQ: + // По правилам IQ обязательно должна содержать ID, причём не пустой + if v.ID == "" { + log.Info("Got an IQ stanza with empty id, discarding") + + return + } + + switch v.Type { + case xmpp.IQTypeGet: + log.Debug("Looks like IQ get query") + + if muc, _ := strings.CutSuffix(v.To, "/"); muc != "" { + if slices.Contains(roomsConnected, muc) { + lastMucActivity.Set(muc, lastServerActivity) + } + } + + var iqStruct jabberSimpleIqGetQuery + var parseError bool + + if err := xml.Unmarshal(v.Query, &iqStruct); err == nil { + switch iqStruct.Xmlns { //nolint:whitespace + + // Запрос номера версии приложения + case "jabber:iq:version": + log.Infof("Got IQ get request for version from %s", v.From) + answer := "" + answer += "buny-jabber-bot" + answer += "1.с_чем-то" + answer += "линупс" + answer += "" + + if id, err := talk.RawInformation( + v.To, + v.From, + v.ID, + xmpp.IQTypeResult, + answer, + ); err != nil { + log.Errorf("Unable to send verion info to jabber server: id=%s, err=%s", id, err) + os.Exit(1) + } + + parseError = false + + // Нам прислали попингуй + case "urn:xmpp:ping": + log.Infof("Got IQ get request for pong from %s", v.From) + + if id, err := talk.RawInformation( + v.To, + v.From, + v.ID, + xmpp.IQTypeResult, + "", + ); err != nil { + log.Errorf("Unable to send pong to jabber server: id=%s, err=%s", id, err) + os.Exit(1) + } + + parseError = false + + // У нас запросили время последней активности. В нашем случае это либо время запуска клиента, либо время + // последней фразы в чяти. + case "jabber:iq:last": + log.Infof("Got IQ get last activity time request from %s", v.From) + + answer := fmt.Sprintf( + "", + time.Now().Unix()-lastActivity, + ) + + if id, err := talk.RawInformation( + v.To, + v.From, + v.ID, + xmpp.IQTypeResult, + answer, + ); err != nil { + log.Errorf("Unable to send last activity time to jabber server: id=%s, err=%s", id, err) + os.Exit(1) + } + + parseError = false + + // Запрос на список поддерживаемых фич - версия, локальное время, итд + case xmpp.XMPPNS_DISCO_INFO: + log.Infof("Got IQ get disco#info request from %s", v.From) + + answer := "" + answer += "" + answer += "" + answer += "" + answer += "" + answer += "" + answer += "" + + if id, err := talk.RawInformation( + v.To, + v.From, + v.ID, + xmpp.IQTypeResult, + answer, + ); err != nil { + log.Errorf("Unable to send disco#info to jabber server: id=%s, err=%s", id, err) + os.Exit(1) + } + + parseError = false + + // Спрашивают список команд, чтобы поуправлять этим клиентом + case xmpp.XMPPNS_DISCO_ITEMS: + log.Infof("Got IQ get disco#items request from %s, answer service unavailable", v.From) + + answer := "" + answer += "" + answer += "" + answer += "" + + if id, err := talk.RawInformation( + v.To, + v.From, + v.ID, + xmpp.IQTypeError, + answer, + ); err != nil { + log.Errorf("Unable to send disco#items to jabber server: id=%s, err=%s", id, err) + os.Exit(1) + } + + parseError = false + + // Запрашивают что-то, о чём мы не имеем представления + default: + log.Info("Got an unknown IQ get request, discarding") + log.Info(spew.Sdump(e)) + + return + } + } else { + parseError = true + } + + // Попробуем распарсить входящий запрос как urn:xmpp:time + if parseError { + var iqStruct jabberTimeIqGetQuery + + if err := xml.Unmarshal(v.Query, &iqStruct); err == nil { + switch iqStruct.Xmlns { + case "urn:xmpp:time": + log.Infof("Got IQ get time request from %s", v.From) + + answer := fmt.Sprintf( + "", + time.Now().UTC().Format(time.RFC3339), + ) + + if id, err := talk.RawInformation( + v.To, + v.From, + v.ID, + xmpp.IQTypeResult, + answer, + ); err != nil { + log.Errorf("Unable to send urn:xmpp:time to jabber server: id=%s, err=%s", id, err) + os.Exit(1) + } + + parseError = false + + default: + log.Info("Got bogus IQ get time request, discarding") + log.Info(spew.Sdump(e)) + + return + } + } else { + parseError = true + } + } + + // Попробуем распарсить входящий запрос как http://jabber.org/protocol/pubsub + if parseError { + var iqStruct jabberPubsubIQGetQuery + + if err := xml.Unmarshal(v.Query, &iqStruct); err == nil { + switch iqStruct.Xmlns { + case xmpp.XMPPNS_PUBSUB: + log.Infof("Got IQ get pubsub request from %s, answer feature unimplemented", v.From) + + answer := "" + answer += "" + answer += "" + answer += "" + + if id, err := talk.RawInformation( + v.To, + v.From, + v.ID, + xmpp.IQTypeError, + answer, + ); err != nil { + log.Errorf( + "Unable to send pubsub feature unimplemented to jabber server: id=%s, err=%s", + id, + err, + ) + + os.Exit(1) + } + + parseError = false + + default: + log.Info("Got unknown IQ get pubsub something request, discarding") + log.Info(spew.Sdump(e)) + parseError = true + } + } else { + parseError = true + } + } + + // Попробуем распарсить входящий запрос как urn:xmpp:ping он приходит при MUC Self-Ping (Schrödinger's Chat) + // Предполагается, что такие респонсы должны приходить только для пинга без серверной оптимизации. + // Хотя идейно мы не поддерживаем работу без серверной оптимизации, но на пинг ответим, нам несложно. + if parseError { + var iqStruct jabberIqPing + + if err := xml.Unmarshal(v.Query, &iqStruct); err == nil { + log.Debugf("Got IQ get request (actually, response) for MUC Self-Ping from %s", v.From) + + if id, err := talk.RawInformation( + v.To, + v.From, + v.ID, + xmpp.IQTypeResult, + "", + ); err != nil { + log.Errorf("Unable to send pong to jabber server: id=%s, err=%s", id, err) + os.Exit(1) + } + } + + parseError = false + } + + // Не знаю, как парсить... залоггируем это дело и пойдём дальше + if parseError { + log.Infof("Does not look like parsable via iqStruct") + log.Info(spew.Sdump(e)) + } + + case xmpp.IQTypeResult: + if muc, _ := strings.CutSuffix(v.To, "/"); muc != "" { + if slices.Contains(roomsConnected, muc) { + lastMucActivity.Set(muc, lastServerActivity) + } + } + + switch { + // Похоже на pong от сервера (по стандарту в ответе нету query, но go-xmpp нам подсовывает это) + case v.From == config.Jabber.Server && v.To == talk.JID() && string(v.Query) == "": + log.Debugf("Got S2C pong answer from %s to %s", v.From, v.To) + serverPingTimestampRx = time.Now().Unix() + + // Похоже на понг второй стадии xep-0410 MUC-Ping-а, который у нас не реализован + case v.To == talk.JID() && string(v.Query) == "": + mucNameMatch := slices.Contains(roomsConnected, v.From) + + if mucNameMatch { + log.Debugf("Got server-optimized MUC pong answer (xep-0410) from %s to %s", v.From, v.To) + } else { + mucNickMatch := false + + for _, room := range roomsConnected { + mucNick := fmt.Sprintf("%s/%s", room, config.Jabber.Nick) + + if v.From == mucNick { + mucNickMatch = true + + break + } + } + + if mucNickMatch { + log.Debugf("Got 2-nd stage MUC pong answer (xep-0410) from %s to %s", v.From, v.To) + // Поскольку никакой логики у нас на этот счёт не предусмотрено, то просто пропускаем ответ + } else { + log.Infof("Got unknown pong from %s to %s", v.From, v.To) + log.Debug(spew.Sdump(e)) + } + } + default: + log.Info("Got an IQ result. Dunno how deal with it, discarding") + log.Debug(spew.Sdump(e)) + } + + // Этот бот не управляется со стороны сервера, поэтому все попытки порулить игнорируем + case xmpp.IQTypeSet: + if muc, _ := strings.CutSuffix(v.To, "/"); muc != "" { + if slices.Contains(roomsConnected, muc) { + lastMucActivity.Set(muc, lastServerActivity) + } + } + + log.Info("Got an IQ request for set something. Answer not implemented") + + answer := "" + answer += "" + answer += "" + answer += "" + + if id, err := talk.RawInformation( + v.To, + v.From, + v.ID, + xmpp.IQTypeError, + answer, + ); err != nil { + log.Errorf("Unable to send set feature unimplemented to jabber server: id=%s, err=%s", + id, + err, + ) + + os.Exit(1) + } + + log.Debug(spew.Sdump(e)) + + // Нам прилетело сообщение об ошибке + case xmpp.IQTypeError: + // Если сервер не хочет пинговаться и отвечает ошибкой на пинг, то наверно он не умеет в пинги, + // хотя если мы его пингуем, значит он анонсировал такой capability. Вот, засранец! + var iqPingStruct jabberIqPing + var parseError = true + + if err := xml.Unmarshal(v.Query, &iqPingStruct); err == nil { + if iqPingStruct.Xmlns == "urn:xmpp:ping" { + if v.From == config.Jabber.Server { + msg := "Server announced that it can answer c2s ping, but gives us an error to such query, " + msg += "fallback to keepalive whitespace pings" + log.Error(msg) + + serverCapsList.Set("urn:xmpp:ping", false) + } else { + log.Errorf("Got 'ping unsupported' message from: %s to: %s", v.From, v.To) + } + } else { + log.Error("Iq parsed as ping, but does not belong to xmlns urn:xmpp:ping") + log.Error(spew.Sdump(e)) + } + + parseError = false + } + + // Это у нас пинг xep-0410 и мы не в комнате, предполагается, что надо бы заджойниться + if parseError { + var iqErrorCancelNotAcceptable jabberIqErrorCancelNotAccepatble + + if err := xml.Unmarshal(v.Query, &iqErrorCancelNotAcceptable); err == nil { + if v.To == talk.JID() { + nick := strings.SplitN(v.From, "/", 2)[1] + + if slices.Contains(roomsConnected, iqErrorCancelNotAcceptable.By) && nick == config.Jabber.Nick { + log.Errorf( + "Got Iq error message from: %s to: %s. Looks like i'm not in MUC anymore", + v.From, v.To, + ) + + time.Sleep(time.Duration(config.Jabber.MucRejoinDelay) * time.Second) + + if _, err := talk.JoinMUCNoHistory(iqErrorCancelNotAcceptable.By, nick); err != nil { + log.Errorf("Looks like connection to server also lost") + + os.Exit(1) + } + } else { + log.Error( + "looks like message parsed as jabberIqErrorCancelNotAcceptable but we're not in given room", + ) + + log.Error(spew.Sdump(e)) + } + } else { + log.Error( + "Looks like message parsed as jabberIqErrorCancelNotAcceptable but addressed not to us", + ) + + log.Error(spew.Sdump(e)) + } + + parseError = false + } + } + + if parseError { + log.Error("Unhandled IQ Error message") + log.Error(spew.Sdump(e)) + } + // Нам прилетело что-то неизвестное из семейства IQ stanza + default: + log.Info("Got an unknown IQ request. Dunno how deal with it, discarding") + log.Info(spew.Sdump(e)) + } + + // Смена статуса участника + case xmpp.Presence: + if muc, _ := strings.CutSuffix(v.To, "/"); muc != "" { + if slices.Contains(roomsConnected, muc) { + lastMucActivity.Set(muc, lastServerActivity) + } + } + + if v.Type == xmpp.IQTypeError { + // Это событие происходит, когда из чятика выходит другой инстанс клиента. + // Такое бывает, когда 2 инстанса с одинаковым ресурсом, например, начинают "драться" за возможность + // остаться на сервере. Ситуация, которую допускать нельзя, на самом деле, потому что рано или поздно такого + // клиента забанят из-за спама "вошёл-вышел". + // Однако, это не значит, что такую ситуацию мы не должны корректным образом обрабатывать. + if v.Type == "unavailable" { + log.Error("Presence notification - looks like another instance of client leaves room") + + if slices.Contains(roomsConnected, v.From) { + go joinMuc(v.From) + } + } else { + log.Errorf("Presence notification, Type: %s, From: %s, Show: %s, Status: %s", + v.Type, v.From, v.Show, v.Status) + log.Errorf(spew.Sdump(v)) + } + } else { + log.Infof("Presence notification, Type: %s, From: %s, To: %s Show: %s, Status: %s, Affilation: %s, Role: %s, JID: %s", + v.Type, v.From, v.To, v.Show, v.Status, v.Affilation, v.Role, v.JID) + + // Это наш собственный Presence + if v.Show == "" && v.Status == "" { + if from := strings.Split(v.From, "/"); from[1] == config.Jabber.Nick { + roomsConnected = append(roomsConnected, from[0]) + } + } + + if err := buny(v); err != nil { + os.Exit(1) + } + } + + // Ответ на запрос поддерживаемых фич, который "http://jabber.org/protocol/disco#info" + case xmpp.DiscoResult: + if muc, _ := strings.CutSuffix(v.To, "/"); muc != "" { + if slices.Contains(roomsConnected, muc) { + lastMucActivity.Set(muc, lastServerActivity) + } + } + + // Я видел 2 типа disco result и они отличались только []identities. Попробуем вытащить известный identity + // TODO: проверять адресата v.To + for _, ident := range v.Identities { + switch ident.Category { + case "server": + // Конкретно сейчас нас интересует только поддержка c2s ping + for _, feature := range v.Features { + log.Debugf("Server %s announced that it supports feature: %s", v.From, feature) + serverCapsList.Set(feature, true) + } + + serverCapsQueried = true + + case "conference": + mucCaps := make(map[string]bool) + + for _, feature := range v.Features { + log.Debugf("MUC %s announced that it supports feature: %s", v.From, feature) + mucCaps[feature] = true + } + + mucCapsList.Set(v.From, mucCaps) + + case "pubsub": + log.Debugf("PubSub component %s reply to disco#info, skipping", v.From) + + default: + log.Debug("Got unknown reply to disco#info") + log.Debug(spew.Sdump(e)) + } + } + + // Это что-то неизвестное, подампим событие в лог + default: + log.Info(spew.Sdump(e)) + } +} + +/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */ diff --git a/globals.go b/globals.go new file mode 100644 index 0000000..b5d5012 --- /dev/null +++ b/globals.go @@ -0,0 +1,65 @@ +package main + +import ( + "os" + + "github.com/eleksir/go-xmpp" +) + +// Config - это у нас глобальная штука :) +var config myConfig + +// Списки тех, кого мы не баним никогда +var whiteList myWhiteList + +// Списки злодеев, которых мы банм +var blackList myBlackList + +// Ставится в true, если мы получили сигнал на выключение +var shutdown = false + +// Чтобы не организовывать драку за установку коннекта +var connecting = false + +// Глобальное состояние соединения +var isConnected = false + +// Канал, в который приходят уведомления для хэндлера сигналов от траппера сигналов +var sigChan = make(chan os.Signal, 1) + +// Основной инстанс xmpp-клиента +var talk *xmpp.Client + +// Опции подключения к xmpp-серверу +var options *xmpp.Options + +// Список комнат, в которых находится бот +var roomsConnected []string + +// Время последней активности, нужно для jabber:iq:last +var lastActivity int64 + +// Время последней активности, нужно для c2s пингов - посылаем пинги, только если давненько ничего не приходило с +// сервера +var lastServerActivity int64 + +// Время последней активности MUC-ов, нужно для пингов - посылаем пинги, только если давненько ничего не приходило из +// muc-ов +var lastMucActivity = NewCollection() + +// Получен ли ответ на запрос disco#info к серверу +var serverCapsQueried bool + +// sync.Map-ка с капабилити сервера +var serverCapsList = NewCollection() + +// sync.Map-ка с комнатами и их capability +var mucCapsList = NewCollection() + +// Время, когда был отправлен c2s ping +var serverPingTimestampTx int64 + +// Время, когда был принят s2c pong +var serverPingTimestampRx int64 + +/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */ diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b2e1d2b --- /dev/null +++ b/go.mod @@ -0,0 +1,16 @@ +module buny-jabber-bot + +go 1.20 + +require ( + github.com/davecgh/go-spew v1.1.1 + github.com/eleksir/go-xmpp v1.3.0 + github.com/hjson/hjson-go v3.3.0+incompatible + github.com/sirupsen/logrus v1.9.3 + golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 +) + +require ( + golang.org/x/crypto v0.11.0 // indirect + golang.org/x/sys v0.10.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..1cd2df2 --- /dev/null +++ b/go.sum @@ -0,0 +1,24 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/eleksir/go-xmpp v1.3.0 h1:A4vonXmHJ+3W4LW+BDM24GkAT0zRMhCAMZhAqTRarLE= +github.com/eleksir/go-xmpp v1.3.0/go.mod h1:KnvbXvUpttEVFl8l5eDO2Q7J97ZxtTMvOsDWULD2Rn8= +github.com/hjson/hjson-go v3.3.0+incompatible h1:Rqr+Ya+0aCJMjaE4s8E9YKvuJLuLVpEvz4ONum52vnI= +github.com/hjson/hjson-go v3.3.0+incompatible/go.mod h1:qsetwF8NlsTsOTwZTApNlTCerV+b2GjYRRcIk4JMFio= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA= +golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= +golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 h1:MGwJjxBy0HJshjDNfLsYO8xppfqWlA5ZT9OhtUUhTNw= +golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA= +golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/lib.go b/lib.go new file mode 100644 index 0000000..28a8a7b --- /dev/null +++ b/lib.go @@ -0,0 +1,696 @@ +package main + +import ( + "encoding/json" + "fmt" + "math/rand" + "os" + "path/filepath" + "syscall" + "time" + + "github.com/eleksir/go-xmpp" + "github.com/hjson/hjson-go" + log "github.com/sirupsen/logrus" + "golang.org/x/exp/slices" +) + +// Хэндлер сигналов закрывает все бд, все сетевые соединения и сваливает из приложения +func sigHandler() { + log.Debug("Installing signal handler") + + for s := range sigChan { + switch s { + case syscall.SIGINT: + log.Infoln("Got SIGINT, quitting") + case syscall.SIGTERM: + log.Infoln("Got SIGTERM, quitting") + case syscall.SIGQUIT: + log.Infoln("Got SIGQUIT, quitting") + + // Заходим на новую итерацию, если у нас "неинтересный" сигнал + default: + continue + } + + // Чтобы не срать в логи ошибками, проставим shutdown state приложения в true + shutdown = true + + if isConnected && !shutdown { + log.Debug("Try to set our presence to Unavailable and status to Offline") + + // Вот тут понадобится коллекция известных пользователей, чтобы им разослать presence, что бот свалил в offline + // Пока за неимением лучшего сообщим об этом самим себе + for _, room := range roomsConnected { + if _, err := talk.SendPresence( + xmpp.Presence{ //nolint:exhaustruct + To: room, + Status: "Offline", + Type: "unavailable", + }, + ); err != nil { + log.Infof("Unable to send presence to jabber server: %s", err) + } + } + + // И закрываем соединение + log.Debugf("Closing connection to jabber server") + + if err := talk.Close(); err != nil { + log.Infof("Unable to close connection to jabber server: %s", err) + } + } + + os.Exit(0) + } +} + +// Читает и валидирует конфиг, а также выставляет некоторые default-ы, если значений для параметров в конфиге нет +func readConfig() { + configLoaded := false + executablePath, err := os.Executable() + + if err != nil { + log.Errorf("Unable to get current executable path: %s", err) + } + + configJSONPath := fmt.Sprintf("%s/data/config.json", filepath.Dir(executablePath)) + + locations := []string{ + "~/.buny-jabber-bot.json", + "~/buny-jabber-bot.json", + "/etc/buny-jabber-bot.json", + configJSONPath, + } + + for _, location := range locations { + fileInfo, err := os.Stat(location) + + // Предполагаем, что файла либо нет, либо мы не можем его прочитать, второе надо бы логгировать, но пока забьём + if err != nil { + continue + } + + // Конфиг-файл длинноват для конфига, попробуем следующего кандидата + if fileInfo.Size() > 65535 { + log.Warnf("Config file %s is too long for config, skipping", location) + + continue + } + + buf, err := os.ReadFile(location) + + // Не удалось прочитать, попробуем следующего кандидата + if err != nil { + log.Warnf("Skip reading config file %s: %s", location, err) + + continue + } + + // Исходя из документации, hjson какбы умеет парсить "кривой" json, но парсит его в map-ку. + // Интереснее на выходе получить структурку: то есть мы вначале конфиг преобразуем в map-ку, затем эту map-ку + // сериализуем в json, а потом json превращаем в структурку. Не очень эффективно, но он и нечасто требуется. + var sampleConfig myConfig + var tmp map[string]interface{} + err = hjson.Unmarshal(buf, &tmp) + + // Не удалось распарсить - попробуем следующего кандидата + if err != nil { + log.Warnf("Skip parsing config file %s: %s", location, err) + + continue + } + + tmpjson, err := json.Marshal(tmp) + + // Не удалось преобразовать map-ку в json + if err != nil { + log.Warnf("Skip parsing config file %s: %s", location, err) + + continue + } + + if err := json.Unmarshal(tmpjson, &sampleConfig); err != nil { + log.Warnf("Skip parsing config file %s: %s", location, err) + + continue + } + + // Значения для Jabber-клиента + if sampleConfig.Jabber.Server == "" { + sampleConfig.Jabber.Server = "localhost" + log.Errorf("Jabber server is not defined in config, using localhost") + } + + if sampleConfig.Jabber.Port == 0 { + sampleConfig.Jabber.Port = 5552 + + if sampleConfig.Jabber.Ssl { + if !sampleConfig.Jabber.StartTLS { + sampleConfig.Jabber.Port = 5553 + } + } + + log.Infof("Jabber port is not defined in config, using 6667") + } + + if !sampleConfig.Jabber.Ssl { + sampleConfig.Jabber.StartTLS = false + } + + if !sampleConfig.Jabber.Ssl || !sampleConfig.Jabber.StartTLS { + sampleConfig.Jabber.SslVerify = false + } + + // sampleConfig.Jabber.InsecureAllowUnencryptedAuth = false, если не задан + + if sampleConfig.Jabber.ConnectionTimeout == 0 { + sampleConfig.Jabber.ConnectionTimeout = 10 + } + + if sampleConfig.Jabber.ReconnectDelay == 0 { + sampleConfig.Jabber.ReconnectDelay = 3 + } + + if sampleConfig.Jabber.ServerPingDelay == 0 { + sampleConfig.Jabber.ServerPingDelay = 60 + } + + if sampleConfig.Jabber.MucPingDelay == 0 { + sampleConfig.Jabber.MucPingDelay = 900 + } + + if sampleConfig.Jabber.MucRejoinDelay == 0 { + sampleConfig.Jabber.MucRejoinDelay = 3 + } + + if sampleConfig.Jabber.PingSplayDelay == 0 { + sampleConfig.Jabber.PingSplayDelay = 3 + } + + if sampleConfig.Jabber.Nick == "" { + log.Errorf("Jabber nick is not defined in config, quitting") + os.Exit(1) + } + + if sampleConfig.Jabber.Resource == "" { + sampleConfig.Jabber.Resource = "buny bot" + } + + if sampleConfig.Jabber.User == "" { + sampleConfig.Jabber.User = fmt.Sprintf("%s@%s", sampleConfig.Jabber.Nick, sampleConfig.Jabber.Server) + } + + // Если sampleConfig.Jabber.Password не задан, то авторизации не будет + // Если sampleConfig.Jabber.Sasl не задан, то авторизация происходит через NickServ + + // Нам бот нужен в каких-то чат-румах, а не "просто так" + if len(sampleConfig.Jabber.Channels) < 1 { + log.Errorf("No jabber channels/rooms defined in config, quitting") + os.Exit(1) + } + + if sampleConfig.Loglevel == "" { + sampleConfig.Loglevel = "info" + } + + // sampleConfig.Log = "" if not set + + config = sampleConfig + configLoaded = true + log.Infof("Using %s as config file", location) + + break + } + + if !configLoaded { + log.Error("Config was not loaded! Refusing to start.") + os.Exit(1) + } +} + +// Читает и валидирует белые списки пользователей +func readWhitelist() { + whitelistLoaded := false + executablePath, err := os.Executable() + + if err != nil { + log.Errorf("Unable to get current executable path: %s", err) + } + + whitelistJSONPath := fmt.Sprintf("%s/data/whitelist.json", filepath.Dir(executablePath)) + + locations := []string{ + "~/.buny-jabber-bot-whitelist.json", + "~/buny-jabber-bot-whitelist.json", + "/etc/buny-jabber-bot-whitelist.json", + whitelistJSONPath, + } + + for _, location := range locations { + fileInfo, err := os.Stat(location) + + // Предполагаем, что файла либо нет, либо мы не можем его прочитать, второе надо бы логгировать, но пока забьём + if err != nil { + continue + } + + // Файл белого списка длинноват для белого списка, попробуем следующего кандидата + if fileInfo.Size() > 2097152 { + log.Warnf("Whitelist file %s is too long for whitelist, skipping", location) + + continue + } + + buf, err := os.ReadFile(location) + + // Не удалось прочитать, попробуем следующего кандидата + if err != nil { + log.Warnf("Skip reading whitelist file %s: %s", location, err) + + continue + } + + // Исходя из документации, hjson какбы умеет парсить "кривой" json, но парсит его в map-ку. + // Интереснее на выходе получить структурку: то есть мы вначале конфиг преобразуем в map-ку, затем эту map-ку + // сериализуем в json, а потом json превращаем в структурку. Не очень эффективно, но он и нечасто требуется. + var sampleWhitelist myWhiteList + var tmp map[string]interface{} + err = hjson.Unmarshal(buf, &tmp) + + // Не удалось распарсить - попробуем следующего кандидата + if err != nil { + log.Warnf("Skip parsing whitelist file %s: %s", location, err) + + continue + } + + tmpjson, err := json.Marshal(tmp) + + // Не удалось преобразовать map-ку в json + if err != nil { + log.Warnf("Skip parsing whitelist file %s: %s", location, err) + + continue + } + + if err := json.Unmarshal(tmpjson, &sampleWhitelist); err != nil { + log.Warnf("Skip parsing whitelist file %s: %s", location, err) + + continue + } + + whiteList = sampleWhitelist + whitelistLoaded = true + log.Infof("Using %s as whiteList file", location) + + break + } + + if !whitelistLoaded { + log.Error("Whitelist was not loaded! Refusing to start.") + os.Exit(1) + } +} + +// Читает и валидирует чёрные списки пользователей +func readBlacklist() { + blacklistLoaded := false + executablePath, err := os.Executable() + + if err != nil { + log.Errorf("Unable to get current executable path: %s", err) + } + + whitelistJSONPath := fmt.Sprintf("%s/data/blacklist.json", filepath.Dir(executablePath)) + + locations := []string{ + "~/.buny-jabber-bot-blacklist.json", + "~/buny-jabber-bot-blacklist.json", + "/etc/buny-jabber-bot-blacklist.json", + whitelistJSONPath, + } + + for _, location := range locations { + fileInfo, err := os.Stat(location) + + // Предполагаем, что файла либо нет, либо мы не можем его прочитать, второе надо бы логгировать, но пока забьём + if err != nil { + continue + } + + // Файл чёрного списка длинноват для чёрного списка, попробуем следующего кандидата + if fileInfo.Size() > 16777216 { + log.Warnf("Blacklist file %s is too long for blacklist, skipping", location) + + continue + } + + buf, err := os.ReadFile(location) + + // Не удалось прочитать, попробуем следующего кандидата + if err != nil { + log.Warnf("Skip reading blacklist file %s: %s", location, err) + + continue + } + + // Исходя из документации, hjson какбы умеет парсить "кривой" json, но парсит его в map-ку. + // Интереснее на выходе получить структурку: то есть мы вначале конфиг преобразуем в map-ку, затем эту map-ку + // сериализуем в json, а потом json превращаем в структурку. Не очень эффективно, но он и нечасто требуется. + var sampleBlacklist myBlackList + var tmp map[string]interface{} + err = hjson.Unmarshal(buf, &tmp) + + // Не удалось распарсить - попробуем следующего кандидата + if err != nil { + log.Warnf("Skip parsing blacklist file %s: %s", location, err) + + continue + } + + tmpjson, err := json.Marshal(tmp) + + // Не удалось преобразовать map-ку в json + if err != nil { + log.Warnf("Skip parsing blacklist file %s: %s", location, err) + + continue + } + + if err := json.Unmarshal(tmpjson, &sampleBlacklist); err != nil { + log.Warnf("Skip parsing whitelist file %s: %s", location, err) + + continue + } + + blackList = sampleBlacklist + blacklistLoaded = true + log.Infof("Using %s as blacklist file", location) + + break + } + + if !blacklistLoaded { + log.Error("Blacklist was not loaded! Refusing to start.") + os.Exit(1) + } +} + +func establishConnection() { + var err error + + if connecting && !isConnected { + return + } + + // проставляем глобальные переменные + connecting = true + isConnected = false + roomsConnected = make([]string, 0) + + for { + talk, err = options.NewClient() + + if err != nil { + log.Error(err) + + os.Exit(1) + } else { + break + } + } + + // По идее keepalive должен же проходить только, если мы уже на сервере, так? + for { + if _, err := talk.SendKeepAlive(); err != nil { + log.Errorf("Try to send initial KeepAlive, got error: %s", err) + time.Sleep(time.Duration(config.Jabber.ReconnectDelay) * time.Second) + + // Понятно, что мы уже не в процессе установления соединения, а произошла ошибка + connecting = false + os.Exit(1) + } else { + log.Info("Connected") + + break + } + } + + // Джойнимся к чятикам, но делаем это в фоне, чтобы не блочиться на ошибках, например, если бота забанили + for _, room := range config.Jabber.Channels { + go joinMuc(room) + } + + if _, err := talk.SendPresence( + xmpp.Presence{ //nolint:exhaustruct + Status: "Ready to chat", + }, + ); err != nil { + log.Infof("Unable to send presence to jabber server: %s", err) + + os.Exit(1) + } + + lastActivity = time.Now().Unix() + connecting = false + isConnected = true + + log.Debugf("Sending disco#info to %s", config.Jabber.Server) + + _, err = talk.DiscoverInfo(talk.JID(), config.Jabber.Server) + + if err != nil { + log.Infof("Unable to send disco#info to jabber server: %s", err) + + os.Exit(1) + } +} + +func reEstablishConnection() { + // Серверные capabilities нам *уже* неизвестны + serverCapsQueried = false + serverCapsList = NewCollection() + mucCapsList = NewCollection() + lastMucActivity = NewCollection() + + // Пока соединение не установлено, мы про пинги ничего не знаем + serverPingTimestampRx = time.Now().Unix() // Считаем, что если коннект запустился, то первый пинг успешен + serverPingTimestampTx = 0 + time.Sleep(time.Duration(config.Jabber.ReconnectDelay) * time.Second) + connecting = false + establishConnection() +} + +// Джойнится к конференциям/каналам/комнатам в джаббере +func joinMuc(room string) { + log.Debugf("Sending disco#info from %s to %s", talk.JID(), room) + + if _, err := talk.DiscoverInfo(talk.JID(), room); err != nil { + log.Infof("Unable to send disco#info to MUC %s: %s", room, err) + + os.Exit(1) + } + + // Ждём, пока muc нам вернёт список фичей + for i := 0; i < (20 * int(config.Jabber.ConnectionTimeout)); i++ { + var myRoom interface{} + var supported bool + var exist bool + + time.Sleep(50 * time.Millisecond) + + if myRoom, exist = mucCapsList.Get(room); !exist { + // Пока не задискаверилась + continue + } + + if supported, exist = myRoom.(map[string]bool)["muc_unsecured"]; exist { + if supported { + break + } else { + log.Infof("Unable to join to password-protected room. Don't know how to enter passwords :)") + + return + } + } + } + + if _, err := talk.JoinMUCNoHistory(room, config.Jabber.Nick); err != nil { + log.Errorf("Unable to join to MUC: %s", room) + + os.Exit(1) + } else { + log.Infof("Joining to MUC: %s", room) + } + + // Ждём, когда прилетит presence из комнаты, тогда мы точно знаем, что мы вошли + for i := 0; i < (20 * int(config.Jabber.ConnectionTimeout)); i++ { + time.Sleep(50 * time.Millisecond) + + if slices.Contains(roomsConnected, room) { + break + } + } + + // Вот теперь точно можно слать статус + log.Infof("Joined to MUC: %s", room) + + if _, err := talk.SendPresence( + xmpp.Presence{ //nolint:exhaustruct + To: room, + Status: "Ready to chat", + }, + ); err != nil { + log.Infof("Unable to send presence to MUC %s: %s", room, err) + os.Exit(1) + } +} + +// Проверяет живость соединения с сервером +func probeServerLiveness() { + for { + if shutdown { + return + } + + sleepTime := time.Duration(config.Jabber.ServerPingDelay) * 1000 * time.Millisecond + sleepTime += time.Duration(rand.Int63n(1000*config.Jabber.PingSplayDelay)) * time.Millisecond //nolint:gosec + time.Sleep(sleepTime) + + if !isConnected { + continue + } + + // Пингуем, только если не было никакой активности в течение > config.Jabber.ServerPingDelay, + // в худшем случе это будет ~ (config.Jabber.PingSplayDelay * 2) + config.Jabber.PingSplayDelay + // if (time.Now().Unix() - lastServerActivity) < (config.Jabber.ServerPingDelay + config.Jabber.PingSplayDelay) { + // continue + // } + + if serverCapsQueried { // Сервер ответил на disco#info + var value interface{} + var exist bool + + value, exist = serverCapsList.Get("urn:xmpp:ping") + + switch { + // Сервер анонсировал, что умеет в c2s пинги + case exist && value.(bool): + // Таймаут c2s пинга. Возьмём сумму задержки между пингами, добавим таймаут коннекта и добавим + // максимальную корректировку разброса. + txTimeout := config.Jabber.ServerPingDelay + config.Jabber.ConnectionTimeout + txTimeout += config.Jabber.PingSplayDelay + rxTimeout := txTimeout + + rxTimeAgo := time.Now().Unix() - serverPingTimestampRx + + if serverPingTimestampTx > 0 { // Первая пуля от нас ушла... + switch { + // Давненько мы не получали понгов от сервера, вероятно, соединение с сервером утеряно? + case rxTimeAgo > (rxTimeout * 2): + log.Debugf( + "Stall connection detected. No c2s pong for %d seconds", + rxTimeAgo, + ) + + os.Exit(1) + + // По-умолчанию, мы отправляем c2s пинг + default: + log.Debugf("Sending c2s ping from %s to %s", talk.JID(), config.Jabber.Server) + + if err := talk.PingC2S(talk.JID(), config.Jabber.Server); err != nil { + os.Exit(1) + } else { + serverPingTimestampTx = time.Now().Unix() + } + } + } else { // Первая пуля пока не вылетела, отправляем + log.Debugf("Sending first c2s ping from %s to %s", talk.JID(), config.Jabber.Server) + + if err := talk.PingC2S(talk.JID(), config.Jabber.Server); err != nil { + os.Exit(1) + } else { + serverPingTimestampTx = time.Now().Unix() + } + } + + // Сервер не анонсировал, что умеет в c2s пинги + default: + log.Debug("Sending keepalive whitespace ping") + + if _, err := talk.SendKeepAlive(); err != nil { + os.Exit(1) + + continue + } + } + } else { // Сервер не ответил на disco#info + log.Debug("Sending keepalive whitespace ping") + + if _, err := talk.SendKeepAlive(); err != nil { + os.Exit(1) + + continue + } + } + } +} + +// Пингует MUC-и, нужно для проверки, что клиент ещё находится в MUC-е +func probeMUCLiveness() { + for { + for _, room := range roomsConnected { + var exist bool + var lastActivityTs interface{} + + // Если записи про комнату нету, то пинговать её бессмысленно. + if lastActivityTs, exist = lastMucActivity.Get(room); !exist { + continue + } + + // Если время последней активности в чятике не превысило + // config.Jabber.ServerPingDelay + config.Jabber.PingSplayDelay, ничего не пингуем + if (time.Now().Unix() - lastActivityTs.(int64)) < (config.Jabber.ServerPingDelay + config.Jabber.PingSplayDelay) { + continue + } + + /* Пинг MUC-а по сценарию без серверной оптимизации мы реализовывать не будем. Это как-то не надёжно. + go func(room string) { + // Небольшая рандомная задержка перед пингом комнаты + sleepTime := time.Duration(rand.Int63n(1000*config.Jabber.PingSplayDelay)) * time.Millisecond //nolint:gosec + time.Sleep(sleepTime) + + if err := talk.PingS2S(talk.JID(), room+"/"+config.Jabber.Nick); err != nil { + reEstablishConnection() + } + }(room) + */ + + var roomMap interface{} + + roomMap, exist = mucCapsList.Get(room) + + // Пинги комнаты проводим, только если она записана, как прошедшая disco#info и поддерживающая + // Server Optimization + if exist && roomMap.(map[string]bool)["http://jabber.org/protocol/muc#self-ping-optimization"] { + go func(room string) { + // Небольшая рандомная задержка перед пингом комнаты + sleepTime := time.Duration(rand.Int63n(1000*config.Jabber.PingSplayDelay)) * time.Millisecond //nolint:gosec + time.Sleep(sleepTime) + + log.Debugf("Sending MUC ping from %s to %s", talk.JID(), room) + + if err := talk.PingS2S(talk.JID(), room); err != nil { + os.Exit(1) + } + }(room) + } + } + + time.Sleep(time.Duration(config.Jabber.MucPingDelay) * time.Second) + } +} + +/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */ diff --git a/main.go b/main.go new file mode 100644 index 0000000..00bc56b --- /dev/null +++ b/main.go @@ -0,0 +1,157 @@ +package main + +import ( + "crypto/tls" + "errors" + "fmt" + "io" + "net" + "os" + "os/signal" + "time" + + "github.com/eleksir/go-xmpp" + log "github.com/sirupsen/logrus" +) + +func init() { + log.SetFormatter(&log.TextFormatter{ //nolint:exhaustruct + DisableQuote: true, + DisableLevelTruncation: false, + DisableColors: true, + FullTimestamp: true, + TimestampFormat: "2006-01-02 15:04:05", + }) + + readConfig() + + // no panic + switch config.Loglevel { + case "fatal": + log.SetLevel(log.FatalLevel) + case "error": + log.SetLevel(log.ErrorLevel) + case "warn": + log.SetLevel(log.WarnLevel) + case "info": + log.SetLevel(log.InfoLevel) + case "debug": + log.SetLevel(log.DebugLevel) + case "trace": + log.SetLevel(log.TraceLevel) + default: + log.SetLevel(log.InfoLevel) + } + + // Пока про capabilities мы ничего не знаем + serverCapsQueried = false + + // Мы пока что не пинговали сервер. + serverPingTimestampRx = 0 + serverPingTimestampTx = 0 +} + +func main() { + // Откроем лог и скормим его логгеру + if config.Log != "" { + logfile, err := os.OpenFile(config.Log, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0644) + + if err != nil { + log.Fatalf("Unable to open log file %s: %s", config.Log, err) + } + + log.SetOutput(logfile) + } + + myLogLevel := log.GetLevel() + log.Warnf("Loglevel set to %v", myLogLevel) + + verboseClient := false + + if myLogLevel == log.TraceLevel { + verboseClient = true + } + + // Самое время вгрузить белые и чёрные списки, если что - получим ошибку в лог. + readWhitelist() + readBlacklist() + + // github.com/mattn/go-xmpp пишет в stdio, нам этого не надо, ловим выхлоп его в logrus с уровнем trace + xmpp.DebugWriter = log.WithFields(log.Fields{"logger": "stdlib"}).WriterLevel(log.TraceLevel) + + options = &xmpp.Options{ //nolint:exhaustruct + Host: fmt.Sprintf("%s:%d", config.Jabber.Server, config.Jabber.Port), + User: config.Jabber.User, + Password: config.Jabber.Password, + Resource: config.Jabber.Resource, + NoTLS: !config.Jabber.Ssl, + StartTLS: config.Jabber.StartTLS, + TLSConfig: &tls.Config{ //nolint:exhaustruct + ServerName: config.Jabber.Server, + InsecureSkipVerify: !config.Jabber.SslVerify, //nolint:gosec + }, + InsecureAllowUnencryptedAuth: config.Jabber.InsecureAllowUnencryptedAuth, + Debug: verboseClient, + Session: false, + Status: "xa", + StatusMessage: "echo bot is starting up", + DialTimeout: 10 * time.Second, + } + + go sigHandler() + signal.Notify(sigChan, os.Interrupt) + // Установим коннект + establishConnection() + + serverPingTimestampRx = time.Now().Unix() // Считаем, что если коннект запустился, то первый пинг успешен + + // Тыкаем сервер палочкой, проверяем, что коннект жив и переустанавливаем его, если он не жив + go probeServerLiveness() + + // Тыкаем muc-и палочкой, проверяем, что они живы и пере-заходим в них, если пинги пропали + go probeMUCLiveness() + + // Гребём сообщения... + for { + // Стриггерилось завершение работы приложения, или соединение не установлено (порвалось, например) + // грести не надо + if shutdown { + break + } + + if !isConnected { + continue + } + + chat, err := talk.Recv() + + if err != nil { + log.Errorf("Unable to get events from server: %s", err) + + switch { + // Стрим не читается, он закрылся с той стороны во время чтения + case errors.Is(err, io.EOF): + os.Exit(1) + + // Пытаемся читать закрытый сокет + case errors.Is(err, net.ErrClosed): + os.Exit(1) + + // Не смогли записать в сокет + case errors.Is(err, net.ErrWriteToConnected): + os.Exit(1) + + // Не сетевая проблема + default: + // Это уже что-то странное. + // Вероятно, ошибка парсинга xml. Собственно, баг сервера, тут мы ничего поделать не можем + + os.Exit(1) + } + } + + parseEvent(chat) + } +} + +/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */ diff --git a/types.go b/types.go new file mode 100644 index 0000000..a2092d9 --- /dev/null +++ b/types.go @@ -0,0 +1,93 @@ +package main + +import ( + "encoding/xml" +) + +// Конфиг +type myConfig struct { + Jabber struct { + Server string `json:"server,omitempty"` + Port int `json:"port,omitempty"` + Ssl bool `json:"ssl,omitempty"` + StartTLS bool `json:"starttls,omitempty"` + SslVerify bool `json:"ssl_verify,omitempty"` + InsecureAllowUnencryptedAuth bool `json:"insecureallowunencryptedauth,omitempty"` + ConnectionTimeout int64 `json:"connection_timeout,omitempty"` + ReconnectDelay int64 `json:"reconnect_delay,omitempty"` + ServerPingDelay int64 `json:"server_ping_delay,omitempty"` + MucPingDelay int64 `json:"muc_ping_delay,omitempty"` + MucRejoinDelay int64 `json:"muc_rejoin_delay,omitempty"` + PingSplayDelay int64 `json:"ping_splay_delay,omitempty"` + Nick string `json:"nick,omitempty"` + Resource string `json:"resource,omitempty"` + User string `json:"user,omitempty"` + Password string `json:"password,omitempty"` + Channels []string `json:"channels"` + OutcastDomains []string `json:"outcast_domains,omitempty"` + } + + Loglevel string `json:"loglevel,omitempty"` + Log string `json:"log,omitempty"` + NoEcho bool `json:"noecho,omitempty"` +} + +// Белый список +type myWhiteList struct { + Whitelist []struct { + RoomName string `json:"room_name,omitempty"` + Jid []string `json:"jid,omitempty"` + WipeBans bool `json:"wipe_bans,omitempty"` + } `json:"whitelist,omitampty"` +} + +type myBlackList struct { + Blacklist []struct { + RoomName string `json:"room_name,omitempty"` + JidRe []string `json:"jid_re,omitempty"` + } `json:"blacklist,omitempty"` +} + +type jabberSimpleIqGetQuery struct { + XMLName xml.Name `xml:"query"` + Text string `xml:",chardata"` + Xmlns string `xml:"xmlns,attr"` + Node string `xml:"node,attr,omitempty"` // для xmlns="http://jabber.org/protocol/disco#items" +} + +type jabberPubsubIQGetQuery struct { + XMLName xml.Name `xml:"pubsub"` + Text string `xml:",chardata"` + Xmlns string `xml:"xmlns,attr"` + Items struct { + Text string `xml:",chardata"` + Node string `xml:"node,attr"` + MaxItems string `xml:"max_items,attr"` + } `xml:"items"` +} + +type jabberTimeIqGetQuery struct { + //