diff options
author | Vasudev Kamath <vasudev@copyninja.info> | 2016-10-23 21:09:40 +0530 |
---|---|---|
committer | Vasudev Kamath <vasudev@copyninja.info> | 2016-10-23 21:09:40 +0530 |
commit | eda4b75b1cff83336e87da90efca9fd6b4ced2c7 (patch) | |
tree | 491317ce50b5d19bc434ccc4b448d1bc70520177 | |
parent | 716c40e4ec45f8d538695225f4f06d541d959084 (diff) | |
parent | 0f14fe83ef53b08bd8fa09670c82f4996c329bdc (diff) | |
download | biboumi-upstream/3.0.tar.gz biboumi-upstream/3.0.tar.bz2 biboumi-upstream/3.0.tar.xz biboumi-upstream/3.0.zip |
New upstream version 3.0upstream/3.0
120 files changed, 15514 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..83cd31d --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# Compiled Object files +*.slo +*.lo +*.o +*.obj + +# Precompiled Headers +*.gch +*.pch + +# Compiled Dynamic libraries +*.so +*.dylib +*.dll + +# Compiled Static libraries +*.lai +*.la +*.a +*.lib + +# Executables +*.exe +*.out +*.app + +# Gcov files +*.gcno +*.gcov +*.gcda + +# Python files +*.pyc +*.pyo + +# Build directories +build/ + +# Clion directory +.idea/
\ No newline at end of file diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..c1cc979 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,103 @@ +before_script: + - uname -a + - whoami + - echo $LANG + - g++ --version + - clang++ --version + - rm -rf build/ + - mkdir build/ + - cd build + +variables: + COMPILER: "g++" + BUILD_TYPE: "Debug" + BOTAN: "-DWITH_BOTAN=1" + CARES: "-DWITH_CARES=1" + SYSTEMD: "-DWITH_SYSTEMD=1" + LIBIDN: "-DWITH_LIBIDN=1" + LITESQL: "-DWITH_LITESQL=1" + +.template:basic_build: &basic_build + stage: build + script: + - "echo Running cmake with the following parameters: -DCMAKE_CXX_COMPILER=${COMPILER} -DCMAKE_BUILD_TYPE=${BUILD_TYPE} ${BOTAN} ${CARES} ${SYSTEMD} ${LIBIDN} ${LITESQL}" + - cmake .. -DCMAKE_CXX_COMPILER=${COMPILER} -DCMAKE_BUILD_TYPE=${BUILD_TYPE} ${BOTAN} ${CARES} ${SYSTEMD} ${LIBIDN} ${LITESQL} + - make biboumi -j$(nproc) + - make check -j$(nproc) + +image: biboumi-test-fedora:latest + +build:1: + variables: + BOTAN: "-DWITHOUT_BOTAN=1" + <<: *basic_build + +build:2: + variables: + CARES: "-DWITHOUT_CARES=1" + <<: *basic_build + +build:3: + variables: + LITESQL: "-DWITHOUT_LITESQL=1" + <<: *basic_build + +build:4: + variables: + LITESQL: "-DWITHOUT_LITESQL=1" + BOTAN: "-DWITHOUT_BOTAN=1" + <<: *basic_build + +build:5: + variables: + LITESQL: "-DWITHOUT_LITESQL=1" + CARES: "-DWITHOUT_CARES=1" + <<: *basic_build + +build:6: + variables: + BOTAN: "-DWITHOUT_BOTAN=1" + CARES: "-DWITHOUT_CARES=1" + <<: *basic_build + +build:6: + variables: + LIBIDN: "-DWITHOUT_LIBIDN=1" + CARES: "-DWITHOUT_CARES=1" + <<: *basic_build + +build:rpm: + stage: build + script: + - cmake .. -DCMAKE_CXX_COMPILER=${COMPILER} -DCMAKE_BUILD_TYPE=${BUILD_TYPE} ${BOTAN} ${CARES} ${SYSTEMD} ${LIBIDN} ${LITESQL} + - make rpm -j$(nproc) + artifacts: + paths: + - build/rpmbuild/RPMS + - build/rpmbuild/SRPMS + when: always + + +.template:basic_test: &basic_test + stage: test + script: + - cmake .. -DCMAKE_CXX_COMPILER=${COMPILER} -DCMAKE_BUILD_TYPE=${BUILD_TYPE} ${BOTAN} ${CARES} ${SYSTEMD} ${LIBIDN} ${LITESQL} + - make biboumi -j$(nproc) + - make check + - make coverage + - mkdir tests_outputs && pushd tests_outputs && make e2e -j$(nproc) -C .. && popd + artifacts: + paths: + - build/coverage/ + - build/tests_outputs/ + when: always + +test:debian: + stage: test + image: biboumi-test-debian:latest + <<: *basic_test + +test:fedora: + stage: test + image: biboumi-test-fedora:latest + <<: *basic_test
\ No newline at end of file diff --git a/CHANGELOG.rst b/CHANGELOG.rst new file mode 100644 index 0000000..75a96cc --- /dev/null +++ b/CHANGELOG.rst @@ -0,0 +1,76 @@ +Version 3.0 - 2016-08-03 +======================== + + - Support multiple-nick sessions: a user can join an IRC channel behind + one single nick, using multiple different clients, at the same time (as + long as each client is using the same bare JID). + - Database support for persistant per-user per-server configuration. Add + `LiteSQL <https://dev.louiz.org/projects/litesql>` as an optional + dependency. + - Add ad-hoc commands that lets each user configure various things + - Support an after-connect command that will be sent to the server + just after the user gets connected to it. + - Support the sending of a PASS command. + - Lets the users configure their username and realname, if the + realname_customization is set to true. + - The remote TLS certificates are checked against the system’s trusted + CAs, unless the user used the configuration option that ignores these + checks. + - Lets the user set a sha-1 hash to identify a server certificate that + should always be trusted. + - Add an outgoing_bind option. + - Add an ad-hoc command to forcefully disconnect a user from one or + more servers. + - Let the user configure the incoming encoding of an IRC server (the + default behaviour remains unchanged: check if it’s valid utf-8 and if + not, decode as latin-1). + - Support `multi-prefix <http://ircv3.net/specs/extensions/multi-prefix-3.1.html>`. + - And of course, many bufixes. + - Run unit tests and a test suite, build the RPM and check many things + automatically using gitlab-ci. + + +Version 2.0 - 2015-05-29 +======================== + + - List channels on an IRC server through an XMPP disco items request + - Let the user send any arbitrary raw IRC command by sending a + message to the IRC server’s JID. + - By default, look for the configuration file as per the XDG + basedir spec. + - Support PING requests in all directions. + - Improve the way we forward received NOTICEs by remembering to + which users we previously sent a private message. This improves the + user experience when talking to NickServ. + - Support joining key-protected channels + - Setting a participant's role/affiliation now results in a change of IRC + mode, instead of being ignored. Setting Toto's affiliation to admin is + now equivalent to “/mode +o Toto” + - Fix the reconnection to the XMPP server to try every 2 seconds + instead of immediately. This avoid hogging resources for nothing + - Asynchronously resolve domain names by optionally using the DNS + library c-ares. + - Add a reload add-hoc command, to reload biboumi's configuration + - Add a fixed_irc_server option. With this option enabled, + biboumi can only connect to the one single IRC server configured + +Version 1.1 - 2014-07-16 +======================== + + - Fix a segmentation fault when connecting to an IRC server using IPv6 + +Version 1.0 - 2014-07-12 +======================== + + - First stable release. + - Mostly complete MUC to IRC, and IRC to MUC support + - Complete handling of private messages + - Full IRC modes support: setting any IRC mode, and receiving notifications + for every mode change + - Verbose connection status notifications + - Conversion from IRC formatting to XHTML-im + - Ad-hoc commands support + - Basic TLS support: auto-accepts all certificates, no cipher + configuration, no way to force usage of TLS (it is used only if + available, clear connection is automatically used as a fallback) + - IPv6 support diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..d6d5ce8 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,309 @@ +cmake_minimum_required(VERSION 3.0) + +project(biboumi) + +set(${PROJECT_NAME}_VERSION_MAJOR 3) +set(${PROJECT_NAME}_VERSION_MINOR 0) +set(${PROJECT_NAME}_VERSION_SUFFIX "") + +set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -std=c++1y -pedantic -Wall -Wextra") +if("${CMAKE_CXX_COMPILER_ID}" STREQUAL "GNU") + set(CMAKE_CXX_FLAGS_DEBUG "${CMAKE_CXX_FLAGS_DEBUG} -fprofile-arcs -ftest-coverage --coverage") +endif() + +# Define a __FILENAME__ macro to get the filename of each file, instead of +# the full path as in __FILE__ +set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -D__FILENAME__='\"$(subst ${CMAKE_SOURCE_DIR}/,,$(abspath $<))\"'") + +# +## Look for external libraries +# +set(CMAKE_MODULE_PATH ${CMAKE_MODULE_PATH} "${CMAKE_CURRENT_SOURCE_DIR}/cmake/Modules/") + +# +## Get the software version +# +set(ARCHIVE_NAME ${CMAKE_PROJECT_NAME}-${${PROJECT_NAME}_VERSION_MAJOR}.${${PROJECT_NAME}_VERSION_MINOR}) +set(RPM_VERSION ${${PROJECT_NAME}_VERSION_MAJOR}.${${PROJECT_NAME}_VERSION_MINOR}) + +if(${PROJECT_NAME}_VERSION_SUFFIX MATCHES ".+") + set(ARCHIVE_NAME ${ARCHIVE_NAME}${${PROJECT_NAME}_VERSION_SUFFIX}) + set(RPM_VERSION ${RPM_VERSION}${${PROJECT_NAME}_VERSION_SUFFIX}) +endif() + +if(${PROJECT_NAME}_VERSION_SUFFIX MATCHES "^~dev$") + # If we are on a dev version, append the hash of the current git HEAD to + # the version + include(FindGit) + if(GIT_FOUND AND EXISTS "${CMAKE_SOURCE_DIR}/.git") + execute_process(COMMAND git --git-dir=${CMAKE_SOURCE_DIR}/.git rev-parse --short HEAD + OUTPUT_VARIABLE GIT_REVISION + OUTPUT_STRIP_TRAILING_WHITESPACE) + if(GIT_REVISION) + set(${PROJECT_NAME}_VERSION_SUFFIX "${${PROJECT_NAME}_VERSION_SUFFIX} (${GIT_REVISION})") + set(ARCHIVE_NAME ${ARCHIVE_NAME}${GIT_REVISION}) + set(RPM_VERSION ${RPM_VERSION}${GIT_REVISION}) + endif() + endif() +endif() + +set(SOFTWARE_VERSION + ${${PROJECT_NAME}_VERSION_MAJOR}.${${PROJECT_NAME}_VERSION_MINOR}${${PROJECT_NAME}_VERSION_SUFFIX}) + +include(CheckFunctionExists) +check_function_exists(ppoll HAVE_PPOLL_FUNCTION) + +# To be able to include the config.h and other files generated by cmake +include_directories("${CMAKE_CURRENT_BINARY_DIR}/src/") +include_directories("${CMAKE_CURRENT_SOURCE_DIR}/src/") +include_directories("${CMAKE_CURRENT_BINARY_DIR}/") + +# +## Documentation +# +execute_process(COMMAND "date" "+%Y-%m-%d" OUTPUT_VARIABLE DOC_DATE + OUTPUT_STRIP_TRAILING_WHITESPACE) +set(MAN_PAGE ${CMAKE_CURRENT_BINARY_DIR}/doc/${PROJECT_NAME}.1) +set(DOC_PAGE ${CMAKE_CURRENT_SOURCE_DIR}/doc/${PROJECT_NAME}.1.rst) +if (NOT PANDOC_EXECUTABLE) + find_program(PANDOC_EXECUTABLE NAMES pandoc + DOC "The pandoc software, to build the man page from the rst documentation") + if(PANDOC_EXECUTABLE) + message(STATUS "Found Pandoc: ${PANDOC_EXECUTABLE}") + set(WITH_DOC true) + file(MAKE_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/doc/) + add_custom_command(OUTPUT ${MAN_PAGE} + COMMAND ${PANDOC_EXECUTABLE} -M date="${DOC_DATE}" -s -t man ${DOC_PAGE} -o ${MAN_PAGE} + DEPENDS ${DOC_PAGE}) + add_custom_target(doc ALL DEPENDS ${MAN_PAGE}) + else() + message(STATUS "Pandoc not found, documentation cannot be built") + endif() +endif() +mark_as_advanced(PANDOC_EXECUTABLE) + +# Look for litesql and enable the database if found +if(WITH_LITESQL) + find_package(LITESQL REQUIRED) +elseif(NOT WITHOUT_LITESQL) + find_package(LITESQL) +endif() + +if(LITESQL_FOUND) + LITESQL_GENERATE_CPP("database/database.xml" + "biboudb" + LITESQL_GENERATED_SOURCES) + + add_library(database STATIC src/database/database.cpp + ${LITESQL_GENERATED_SOURCES}) + target_link_libraries(database ${LITESQL_LIBRARIES}) + if(BOTAN_FOUND) + target_link_libraries(database ${BOTAN_LIBRARIES}) + endif() + set(USE_DATABASE TRUE) +endif() + +add_subdirectory("louloulibs") +include_directories("louloulibs") + +include_directories(${EXPAT_INCLUDE_DIRS}) +include_directories(${ICONV_INCLUDE_DIRS}) +include_directories(${LIBUUID_INCLUDE_DIRS}) + +# If they are found in louloulibs CMakeLists.txt, we inherite these values +if(SYSTEMD_FOUND) + include_directories(${SYSTEMD_INCLUDE_DIRS}) +endif() +if(BOTAN_FOUND) + include_directories(SYSTEM ${BOTAN_INCLUDE_DIRS}) +endif() +if(CARES_FOUND) + include_directories(${CARES_INCLUDE_DIRS}) +endif() + +# +## utils +# +file(GLOB source_src_utils + src/utils/*.[hc]pp) +# Todo, switch to target_sources(utils) when we go cmake >=3.1 only +add_library(src_utils STATIC ${source_src_utils}) +target_link_libraries(src_utils logger config) +if(USE_DATABASE) + target_link_libraries(src_utils database) +endif() + +# +## irclib +# +file(GLOB source_irc + src/irc/*.[hc]pp) +add_library(irc STATIC ${source_irc}) +target_link_libraries(irc network utils logger) + +# +## xmpp +# +file(GLOB source_xmpp + src/xmpp/*.[hc]pp) +add_library(xmpp STATIC ${source_xmpp}) +target_link_libraries(xmpp xmpplib bridge network utils src_utils logger) + +if(USE_DATABASE) + target_link_libraries(xmpp database) + target_link_libraries(irc database) +endif() + +# +## bridge +# +file(GLOB source_bridge + src/bridge/*.[hc]pp) +add_library(bridge STATIC ${source_bridge}) +target_link_libraries(bridge xmpp irc utils logger) + +# +## Main executable +# +add_executable(${PROJECT_NAME} src/main.cpp) +target_link_libraries(${PROJECT_NAME} + xmpp + irc + bridge + utils + src_utils + config) +if(SYSTEMD_FOUND) + target_link_libraries(xmpp ${SYSTEMD_LIBRARIES}) +endif() + +# +## Tests +# +file(GLOB source_tests + tests/*.cpp) +add_executable(test_suite EXCLUDE_FROM_ALL + ${source_tests}) +target_link_libraries(test_suite + xmpplib + xmpp + irc + bridge + utils + config + logger + network) +if(USE_DATABASE) + target_link_libraries(test_suite + database) +endif() + +include(ExternalProject) +ExternalProject_Add(catch + GIT_REPOSITORY "https://lab.louiz.org/louiz/Catch.git" + PREFIX "external" + UPDATE_COMMAND "" + CONFIGURE_COMMAND "" + BUILD_COMMAND "" + INSTALL_COMMAND "" + ) +set_target_properties(catch PROPERTIES EXCLUDE_FROM_ALL TRUE) +ExternalProject_Get_Property(catch SOURCE_DIR) +if(NOT EXISTS ${CMAKE_SOURCE_DIR}/tests/catch.hpp) + target_include_directories(test_suite + PUBLIC "${SOURCE_DIR}/include/" + ) + add_dependencies(test_suite catch) +endif() +add_custom_target(check COMMAND "test_suite" "-s" + DEPENDS test_suite biboumi) +add_custom_target(e2e COMMAND "python3" "${CMAKE_CURRENT_SOURCE_DIR}/tests/end_to_end/" + DEPENDS biboumi) +add_custom_target(e2e_valgrind COMMAND "E2E_BIBOUMI_SUPP_DIR=${CMAKE_CURRENT_SOURCE_DIR}/tests/end_to_end/" "E2E_BIBOUMI_VALGRIND=1" "python3" "${CMAKE_CURRENT_SOURCE_DIR}/tests/end_to_end/" + DEPENDS biboumi) + + +# +## Code coverage +# +if(CMAKE_BUILD_TYPE MATCHES Debug) + include(CodeCoverage) + SETUP_TARGET_FOR_COVERAGE(coverage + test_suite + coverage + ) +endif() + +# +## Install target +# +install(TARGETS ${PROJECT_NAME} RUNTIME DESTINATION bin) +install(FILES ${MAN_PAGE} DESTINATION share/man/man1 OPTIONAL COMPONENT documentation) +install(FILES ${CMAKE_CURRENT_BINARY_DIR}/biboumi.service DESTINATION lib/systemd/system COMPONENT init) +install(FILES conf/biboumi.cfg DESTINATION /etc/biboumi COMPONENT configuration) + +# +## Dist target +## Generate a release tarball from the git sources +# +add_custom_target(dist + COMMAND git archive --prefix=${ARCHIVE_NAME}/ --format=tar HEAD + > ${CMAKE_CURRENT_BINARY_DIR}/${ARCHIVE_NAME}.tar + # Append this specific file that is not part of the git repo + COMMAND tar -rf ${CMAKE_CURRENT_BINARY_DIR}/${ARCHIVE_NAME}.tar -P ${SOURCE_DIR}/single_include/catch.hpp --xform 's|/.*/|${ARCHIVE_NAME}/tests/|g' + # Remove a potential existing archive + COMMAND rm -f ${CMAKE_CURRENT_BINARY_DIR}/${ARCHIVE_NAME}.tar.xz + # Compress the archive + COMMAND xz ${CMAKE_CURRENT_BINARY_DIR}/${ARCHIVE_NAME}.tar + COMMAND ${CMAKE_COMMAND} -E cmake_echo_color --cyan "${ARCHIVE_NAME}.tar.xz created." + WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} + ) +add_dependencies(dist catch) + +add_custom_target(rpm + COMMAND mkdir -p rpmbuild/{BUILD,RPMS,SOURCES,SPECS,SRPMS} + COMMAND rpmbuild --define "_topdir `pwd`/rpmbuild/" --define "_sourcedir `pwd`" -ba biboumi.spec + ) +add_dependencies(rpm dist) + +if(BOTAN_FOUND) + set(STR_WITH_BOTAN "Botan: yes") +else() + set(STR_WITH_BOTAN "Botan: no") +endif() +if(CARES_FOUND) + set(STR_WITH_CARES "c-ares: yes") +else() + set(STR_WITH_CARES "c-ares: no") +endif() +add_custom_target(PrintBuildParameters ALL + ${CMAKE_COMMAND} -E cmake_echo_color --cyan "Compiling ${PROJECT_NAME} with ${STR_WITH_BOTAN}, ${STR_WITH_CARES}") + +configure_file(biboumi.h.cmake src/biboumi.h) + +set(SYSTEMD_SERVICE_TYPE_DOCSTRING "The value used as the Type= in the systemd unit file.") +set(WATCHDOG_SEC_DOCSTRING "The value used as WatchdogSec= in the systemd unit file.") +if(SYSTEMD_FOUND) + set(SYSTEMD_SERVICE_TYPE "notify" CACHE STRING ${SYSTEMD_SERVICE_TYPE_DOCSTRING}) + set(WATCHDOG_SEC "20" CACHE STRING ${WATCHDOG_SEC_DOCSTRING}) +else() + set(SYSTEMD_SERVICE_TYPE "simple" CACHE STRING ${SYSTEMD_SERVICE_TYPE_DOCSTRING}) + set(WATCHDOG_SEC "" CACHE STRING ${WATCHDOG_SEC_DOCSTRING}) +endif() +set(SERVICE_USER_DOCSTRING "The value used as the User= in the systemd unit file.") +if(NOT DEFINED SERVICE_USER) + set(SERVICE_USER "nobody" CACHE STRING ${SERVICE_USER_DOCSTRING}) +endif() +set(SERVICE_GROUP_DOCSTRING "The value used as the Group= in the systemd unit file.") +if(NOT DEFINED SERVICE_GROUP) + set(SERVICE_GROUP "nobody" CACHE STRING ${SERVICE_GROUP_DOCSTRING}) +endif() +configure_file(unit/biboumi.service.cmake biboumi.service) + +# The date MUST be in english format +set(ENV{LANG} "en_US.utf-8") +execute_process(COMMAND "date" "+%a %b %d %Y" OUTPUT_VARIABLE RPM_DATE + OUTPUT_STRIP_TRAILING_WHITESPACE) +unset(ENV{LANG}) + +configure_file(packaging/biboumi.spec.cmake biboumi.spec) @@ -0,0 +1,20 @@ +Copyright (c) 2015 Florent Le Coz + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not +claim that you wrote the original software. If you use this software +in a product, an acknowledgment in the product documentation would be +appreciated but is not required. + +2. Altered source versions must be plainly marked as such, and must not be +misrepresented as being the original software. + +3. This notice may not be removed or altered from any source +distribution. diff --git a/INSTALL.rst b/INSTALL.rst new file mode 100644 index 0000000..1526d7e --- /dev/null +++ b/INSTALL.rst @@ -0,0 +1,161 @@ +INSTALL +======= + +tl;dr +----- + + cmake . && make && ./biboumi + +If that didn’t work, read on. + +Dependencies +------------ + +Build and runtime dependencies: + +Tools: +~~~~~~ + +- A C++14 compiler (clang >= 3.4 or gcc >= 4.9 for example) +- CMake +- pandoc (optional) to build the man page + +Libraries: +~~~~~~~~~~ + +expat_ + Used to parse XML from the XMPP server. + +libiconv_ + Encoding from anything into UTF-8 + +libuuid_ + Generate unique IDs + +libidn_ (optional, but recommended) + Provides the stringprep functionality. Without it, JIDs for IRC users are + not provided. + +c-ares_ (optional, but recommended) + Asynchronously resolve domain names. This offers better reactivity and + performances when connecting to a big number of IRC servers at the same + time. + +libbotan_ 1.11 (optional) + Provides TLS support. Without it, IRC connections are all made in + plain-text mode. + Other branches than the 1.11 are not supported. + +litesql_ (optional) + Provides a way to store various options in a (sqlite3) database. Each user + of the gateway can store their own values (for example their prefered port, + or their IRC password). + +systemd_ (optional) + Provides the support for a systemd service of Type=notify. This is useful only + if you are packaging biboumi in a distribution with Systemd. + + +Configure +--------- + +Configure the build system using cmake, there are many solutions to do that, +the simplest is to just run + + cmake . + +in the current directory. + +The default build type is "Debug", if you want to build a release version, +set the CMAKE_BUILD_TYPE variable to "release", by running this command +instead: + + cmake . -DCMAKE_BUILD_TYPE=release -DCMAKE_INSTALL_PREFIX=/usr + +You can also configure many parameters of the build (like customize CFLAGS, +the install path, choose the compiler, or enabling some options like the +POLLER to use), using the ncurses interface of ccmake: + + ccmake . + +In ccmake, first use 'c' to configure the build system, edit the values you +need and finaly use 'g' to generate the Makefiles to build the system and +quit ccmake. + +You can also configure these options using a -D command line flag. + +The list of available options: + +- POLLER: lets you select the poller used by biboumi, at + compile-time. Possible values are: + + - EPOLL: use the Linux-specific epoll(7). This is the default on Linux. + - POLL: use the standard poll(2). This is the default value on all non-Linux + platforms. + +- WITH_BOTAN and WITHOUT_BOTAN: The first force the usage of the Botan library, + if it is not found, the configuration process will fail. The second will + make the build process ignore the Botan library, it will not be used even + if it's available on the system. If none of these option is specified, the + library will be used if available and will be ignored otherwise. + +- WITH_LIBIDN and WITHOUT_LIBIDN: Just like the WITH(OUT)_BOTAN options, but + for the IDN library + +- WITH_SYSTEMD and WITHOUT_SYSTEMD: Just like the other WITH(OUT)_* options, + but for the Systemd library + +Example: + + cmake . -DCMAKE_BUILD_TYPE=release -DCMAKE_INSTALL_PREFIX=/usr + -DWITH_BOTAN=1 -DWITHOUT_SYSTEMD=1 + +This command will configure the project to build a release, with TLS enabled +(using Botan) but without using Systemd (even if available on the system). + + +Build +----- +Once you’ve configured everything using cmake, build the project + + make + + +Install +------- +And then, optionaly, Install the software system-wide + + make install + + +Testing +------- +You can run the test suite with + + make check + +This project uses the Catch unit test framework, it will be automatically +fetched with cmake, by cloning the github repository. + +You can also check the overall code coverage of this test suite by running + + make coverage + +This requires gcov and lcov to be installed. + + +Run +--- +Run the software using the `biboumi` binary. Read the documentation (the +man page biboumi(1) or the `biboumi.1.rst`_ file) for more information on how +to use biboumi. + +.. _expat: http://expat.sourceforge.net/ +.. _libiconv: http://www.gnu.org/software/libiconv/ +.. _libuuid: http://sourceforge.net/projects/libuuid/ +.. _libidn: http://www.gnu.org/software/libidn/ +.. _libbotan: http://botan.randombit.net/ +.. _c-ares: http://c-ares.haxx.se/ +.. _litesql: http://git.louiz.org/litesql +.. _systemd: https://www.freedesktop.org/wiki/Software/systemd/ +.. _biboumi.1.rst: doc/biboumi.1.rst diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..a7f9348 --- /dev/null +++ b/README.rst @@ -0,0 +1,62 @@ +Biboumi +======= + +Biboumi is an XMPP gateway that connects to IRC servers and translates +between the two protocols. It can be used to access IRC channels using any +XMPP client as if these channels were XMPP MUCs. + +It is written in modern C++14 and makes great efforts to have as little +dependencies and to be as simple as possible. + +The goal is to provide a way to access most of IRC features using any XMPP +client. It doesn’t however try to provide a complete mapping of the +features of both worlds simply because this is not useful and most probably +impossible. For example all IRC modes are not all translatable into an XMPP +features. Some of them are (like +m (mute) or +o (operator) modes), but +some others are IRC-specific. If IRC is the limiting factor (for example +you cannot have a non-ASCII nickname on IRC) then biboumi doesn’t try to +work around this issue: it just enforces the rules of the IRC server by +telling the user that he/she must choose an ASCII-only nickname. An +important goal is to keep the software (and its code) light and simple. + + +Install +------- +Refer to the INSTALL_ file. + + +Usage +----- +Read `the documentation`_. + +Authors +------- +Florent Le Coz (louiz’) <louiz@louiz.org> + + +Contact/Support +--------------- +* XMPP ChatRoom: biboumi@muc.poez.io +* Report a bug: https://dev.louiz.org/projects/biboumi/issues/new + +To contribute, the preferred way is to commit your changes on some +publicly-available git repository (your own, or github +(https://github.com/louiz/biboumi), or a fork on https://lab.louiz.org) and +to notify the developers with a ticket on the bug tracker +(https://dev.louiz.org/projects/biboumi/issues/new), a pull request on +github or a merge request on gitlab. + +Optionally you can come discuss your changes on the XMPP chat room, +beforehand. + + +Licence +------- +Biboumi is Free Software. +(learn more: http://www.gnu.org/philosophy/free-sw.html) + +Biboumi is released under the zlib license. +Please read the COPYING file for details. + +.. _INSTALL: INSTALL.rst +.. _the documentation: doc/biboumi.1.rst diff --git a/biboumi.h.cmake b/biboumi.h.cmake new file mode 100644 index 0000000..beb67d0 --- /dev/null +++ b/biboumi.h.cmake @@ -0,0 +1 @@ +#cmakedefine USE_DATABASE diff --git a/cmake/Modules/CodeCoverage.cmake b/cmake/Modules/CodeCoverage.cmake new file mode 100644 index 0000000..4f54327 --- /dev/null +++ b/cmake/Modules/CodeCoverage.cmake @@ -0,0 +1,213 @@ +# Copyright (c) 2012 - 2015, Lars Bilke +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without modification, +# are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# 3. Neither the name of the copyright holder nor the names of its contributors +# may be used to endorse or promote products derived from this software without +# specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +# ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +# +# +# 2012-01-31, Lars Bilke +# - Enable Code Coverage +# +# 2013-09-17, Joakim Söderberg +# - Added support for Clang. +# - Some additional usage instructions. +# +# USAGE: + +# 0. (Mac only) If you use Xcode 5.1 make sure to patch geninfo as described here: +# http://stackoverflow.com/a/22404544/80480 +# +# 1. Copy this file into your cmake modules path. +# +# 2. Add the following line to your CMakeLists.txt: +# INCLUDE(CodeCoverage) +# +# 3. Set compiler flags to turn off optimization and enable coverage: +# SET(CMAKE_CXX_FLAGS "-g -O0 -fprofile-arcs -ftest-coverage") +# SET(CMAKE_C_FLAGS "-g -O0 -fprofile-arcs -ftest-coverage") +# +# 3. Use the function SETUP_TARGET_FOR_COVERAGE to create a custom make target +# which runs your test executable and produces a lcov code coverage report: +# Example: +# SETUP_TARGET_FOR_COVERAGE( +# my_coverage_target # Name for custom target. +# test_driver # Name of the test driver executable that runs the tests. +# # NOTE! This should always have a ZERO as exit code +# # otherwise the coverage generation will not complete. +# coverage # Name of output directory. +# ) +# +# 4. Build a Debug build: +# cmake -DCMAKE_BUILD_TYPE=Debug .. +# make +# make my_coverage_target +# +# + +# Check prereqs +FIND_PROGRAM( GCOV_PATH gcov ) +FIND_PROGRAM( LCOV_PATH lcov ) +FIND_PROGRAM( GENHTML_PATH genhtml ) +FIND_PROGRAM( GCOVR_PATH gcovr PATHS ${CMAKE_SOURCE_DIR}/tests) + +# Display an error when the target is called. If no error is found, this +# function will be overridden by the real one later in this file +FUNCTION(SETUP_TARGET_FOR_COVERAGE _targetname _testrunner _outputname) + ADD_CUSTOM_TARGET(${_targetname} + COMMAND echo "Coverage is not available: ${ERROR_MSG}" + ) +ENDFUNCTION() + +IF(NOT GCOV_PATH) + set(ERROR_MSG "gcov not found") + return() +ENDIF() +MARK_AS_ADVANCED(GCOV_PATH) +IF(NOT LCOV_PATH) + set(ERROR_MSG "lcov not found") + return() +ENDIF() +MARK_AS_ADVANCED(LCOV_PATH) +IF(NOT GENHTML_PATH) + set(ERROR_MSG "genhtml not found") + return() +ENDIF() +MARK_AS_ADVANCED(GENHTML_PATH) +IF(NOT CMAKE_COMPILER_IS_GNUCXX) + set(ERROR_MSG "Compiler is not gcc") + return() +ENDIF() + +SET(CMAKE_CXX_FLAGS_COVERAGE + "-g -O0 --coverage -fprofile-arcs -ftest-coverage" + CACHE STRING "Flags used by the C++ compiler during coverage builds." + FORCE ) +SET(CMAKE_C_FLAGS_COVERAGE + "-g -O0 --coverage -fprofile-arcs -ftest-coverage" + CACHE STRING "Flags used by the C compiler during coverage builds." + FORCE ) +SET(CMAKE_EXE_LINKER_FLAGS_COVERAGE + "" + CACHE STRING "Flags used for linking binaries during coverage builds." + FORCE ) +SET(CMAKE_SHARED_LINKER_FLAGS_COVERAGE + "" + CACHE STRING "Flags used by the shared libraries linker during coverage builds." + FORCE ) +MARK_AS_ADVANCED( + CMAKE_CXX_FLAGS_COVERAGE + CMAKE_C_FLAGS_COVERAGE + CMAKE_EXE_LINKER_FLAGS_COVERAGE + CMAKE_SHARED_LINKER_FLAGS_COVERAGE ) + +IF ( NOT (CMAKE_BUILD_TYPE STREQUAL "Debug" OR CMAKE_BUILD_TYPE STREQUAL "Coverage")) + MESSAGE( WARNING "Code coverage results with an optimized (non-Debug) build may be misleading" ) +ENDIF() # NOT CMAKE_BUILD_TYPE STREQUAL "Debug" + + +# Param _targetname The name of new the custom make target +# Param _testrunner The name of the target which runs the tests. +# MUST return ZERO always, even on errors. +# If not, no coverage report will be created! +# Param _outputname lcov output is generated as _outputname.info +# HTML report is generated in _outputname/index.html +# Optional fifth parameter is passed as arguments to _testrunner +# Pass them in list form, e.g.: "-j;2" for -j 2 +FUNCTION(SETUP_TARGET_FOR_COVERAGE _targetname _testrunner _outputname) + + # Setup target + ADD_CUSTOM_TARGET(${_targetname} + + # Cleanup lcov + COMMAND ${LCOV_PATH} --directory . --zerocounters + + # Create baseline coverage data file + COMMAND ${LCOV_PATH} -c -i -d . -o ${_outputname}.baseline.info -q + + # Run tests + COMMAND ${_testrunner} ${ARGV3} + + # Capturing lcov counters and generating report + COMMAND ${LCOV_PATH} --directory . --capture --output-file ${_outputname}.info -q + # Combine the baseline and the test data + COMMAND ${LCOV_PATH} -a ${_outputname}.info -a ${_outputname}.baseline.info -o ${_outputname}.info -q + + # Remove information about source files that are not part of + # the test (system file, external libraries, etc) + COMMAND ${LCOV_PATH} --remove ${_outputname}.info 'tests/*' '/usr/*' 'external/*' --output-file ${_outputname}.info.cleaned -q + + # Generate the report + COMMAND ${GENHTML_PATH} -o ${_outputname} ${_outputname}.info.cleaned + + # Clean the temporary files we created + COMMAND ${CMAKE_COMMAND} -E remove ${_outputname}.info ${_outputname}.info.cleaned + + WORKING_DIRECTORY ${CMAKE_BINARY_DIR} + COMMENT "Resetting code coverage counters to zero.\nProcessing code coverage counters and generating report." + ) + + # Show info where to find the report + ADD_CUSTOM_COMMAND(TARGET ${_targetname} POST_BUILD + COMMAND ; + COMMENT "Open ./${_outputname}/index.html in your browser to view the coverage report." + ) + +ENDFUNCTION() # SETUP_TARGET_FOR_COVERAGE + +# Param _targetname The name of new the custom make target +# Param _testrunner The name of the target which runs the tests +# Param _outputname cobertura output is generated as _outputname.xml +# Optional fourth parameter is passed as arguments to _testrunner +# Pass them in list form, e.g.: "-j;2" for -j 2 +FUNCTION(SETUP_TARGET_FOR_COVERAGE_COBERTURA _targetname _testrunner _outputname) + + IF(NOT PYTHON_EXECUTABLE) + MESSAGE(FATAL_ERROR "Python not found! Aborting...") + ENDIF() # NOT PYTHON_EXECUTABLE + + IF(NOT GCOVR_PATH) + MESSAGE(FATAL_ERROR "gcovr not found! Aborting...") + ENDIF() # NOT GCOVR_PATH + MARK_AS_ADVANCED(GCOVR_PATH) + + ADD_CUSTOM_TARGET(${_targetname} + + # Run tests + ${_testrunner} ${ARGV3} + + # Running gcovr + COMMAND ${GCOVR_PATH} -x -r ${CMAKE_SOURCE_DIR} -e '${CMAKE_SOURCE_DIR}/tests/' -o ${_outputname}.xml + WORKING_DIRECTORY ${CMAKE_BINARY_DIR} + COMMENT "Running gcovr to produce Cobertura code coverage report." + ) + + # Show info where to find the report + ADD_CUSTOM_COMMAND(TARGET ${_targetname} POST_BUILD + COMMAND ; + COMMENT "Cobertura code coverage report saved in ${_outputname}.xml." + ) + +ENDFUNCTION() # SETUP_TARGET_FOR_COVERAGE_COBERTURA diff --git a/cmake/Modules/FindLITESQL.cmake b/cmake/Modules/FindLITESQL.cmake new file mode 100644 index 0000000..91155bb --- /dev/null +++ b/cmake/Modules/FindLITESQL.cmake @@ -0,0 +1,76 @@ +# - Find LiteSQL +# +# Find the LiteSQL library, and defines a function to generate C++ files +# from the database xml file using litesql-gen fro +# +# This module defines the following variables: +# LITESQL_FOUND - True if library and include directory are found +# If set to TRUE, the following are also defined: +# LITESQL_INCLUDE_DIRS - The directory where to find the header file +# LITESQL_LIBRARIES - Where to find the library file +# LITESQL_GENERATE_CPP - A function, to be used like this: +# LITESQL_GENERATE_CPP("db/database.xml" # The file defining the db schemas +# "database" # The name of the C++ “module” +# # that will be generated +# LITESQL_GENERATED_SOURCES # Variable containing the +# resulting C++ files to compile +# +# For conveniance, these variables are also set. They have the same values +# than the variables above. The user can thus choose his/her prefered way +# to write them. +# LITESQL_INCLUDE_DIR +# LITESQL_LIBRARY +# +# This file is in the public domain + +find_path(LITESQL_INCLUDE_DIRS NAMES litesql.hpp + DOC "The LiteSQL include directory") + +find_library(LITESQL_LIBRARIES NAMES litesql + DOC "The LiteSQL library") + +foreach(DB_TYPE sqlite postgresql mysql ocilib) + string(TOUPPER ${DB_TYPE} DB_TYPE_UPPER) + find_library(LITESQL_${DB_TYPE_UPPER}_LIB_PATH NAMES litesql_${DB_TYPE} + DOC "The ${DB_TYPE} backend for LiteSQL") + if(LITESQL_${DB_TYPE_UPPER}_LIB_PATH) + list(APPEND LITESQL_LIBRARIES ${LITESQL_${DB_TYPE_UPPER}_LIB_PATH}) + endif() + mark_as_advanced(LITESQL_${DB_TYPE_UPPER}_LIB_PATH) +endforeach() + +find_program(LITESQLGEN_EXECUTABLE NAMES litesql-gen + DOC "The utility that creates .h and .cpp files from a xml database description") + +# Use some standard module to handle the QUIETLY and REQUIRED arguments, and +# set LITESQL_FOUND to TRUE if these two variables are set. +include(FindPackageHandleStandardArgs) +find_package_handle_standard_args(LITESQL REQUIRED_VARS LITESQL_LIBRARIES LITESQL_INCLUDE_DIRS + LITESQLGEN_EXECUTABLE) + +# Compatibility for all the ways of writing these variables +if(LITESQL_FOUND) + set(LITESQL_INCLUDE_DIR ${LITESQL_INCLUDE_DIRS}) + set(LITESQL_LIBRARY ${LITESQL_LIBRARIES}) +endif() + +mark_as_advanced(LITESQL_INCLUDE_DIRS LITESQL_LIBRARIES LITESQLGEN_EXECUTABLE) + + +# LITESQL_GENERATE_CPP function + +function(LITESQL_GENERATE_CPP + SOURCE_FILE OUTPUT_NAME OUTPUT_SOURCES) + set(${OUTPUT_SOURCES}) + add_custom_command( + OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/${OUTPUT_NAME}.cpp" + "${CMAKE_CURRENT_BINARY_DIR}/${OUTPUT_NAME}.hpp" + COMMAND ${LITESQLGEN_EXECUTABLE} + ARGS -t c++ --output-dir=${CMAKE_CURRENT_BINARY_DIR} ${CMAKE_CURRENT_SOURCE_DIR}/${SOURCE_FILE} + DEPENDS ${SOURCE_FILE} + COMMENT "Running litesql-gen on ${SOURCE_FILE}" + VERBATIM) + list(APPEND ${OUTPUT_SOURCES} "${CMAKE_CURRENT_BINARY_DIR}/${OUTPUT_NAME}.cpp") + set_source_files_properties(${${OUTPUT_SOURCES}} PROPERTIES GENERATED TRUE) + set(${OUTPUT_SOURCES} ${${OUTPUT_SOURCES}} PARENT_SCOPE) +endfunction() diff --git a/conf/biboumi.cfg b/conf/biboumi.cfg new file mode 100644 index 0000000..e6b8ed5 --- /dev/null +++ b/conf/biboumi.cfg @@ -0,0 +1,7 @@ +hostname=biboumi.example.com +password=secret +db_name=/var/lib/biboumi/biboumi.sqlite +log_file=/var/log/biboumi/biboumi.log +log_level=0 +admin= +port=5347 diff --git a/database/database.xml b/database/database.xml new file mode 100644 index 0000000..f102db0 --- /dev/null +++ b/database/database.xml @@ -0,0 +1,46 @@ +<?xml version="1.0"?> +<!DOCTYPE database SYSTEM "litesql.dtd"> + +<database name="BibouDB" namespace="db"> + <object name="IrcServerOptions"> + <field name="owner" type="string" length="3071"/> + <field name="server" type="string" length="3071"/> + + <field name="pass" type="string" length="1024" default=""/> + <field name="afterConnectionCommand" type="string" length="510" default=""/> + <field name="tlsPorts" type="string" length="4096" default="6697;6670" /> + <field name="ports" type="string" length="4096" default="6667" /> + <field name="username" type="string" length="1024" default=""/> + <field name="realname" type="string" length="1024" default=""/> + <field name="verifyCert" type="boolean" default="true"/> + <field name="trustedFingerprint" type="string"/> + + <field name="encodingOut" type="string" default="ISO-8859-1"/> + <field name="encodingIn" type="string" default="ISO-8859-1"/> + + <index unique="true"> + <indexfield name="owner"/> + <indexfield name="server"/> + </index> + </object> + + <object name="IrcChannelOptions"> + <field name="owner" type="string" length="3071"/> + <field name="server" type="string" length="3071"/> + <field name="channel" type="string" length="1024"/> + + <field name="encodingOut" type="string"/> + <field name="encodingIn" type="string"/> + + <index unique="true"> + <indexfield name="owner"/> + <indexfield name="server"/> + <indexfield name="channel"/> + </index> + </object> + + <object name="LogLine"> + <field name="date" type="date" /> + <field name="body" type="string" length="4096"/> + </object> +</database> diff --git a/doc/biboumi.1.rst b/doc/biboumi.1.rst new file mode 100644 index 0000000..dd365f0 --- /dev/null +++ b/doc/biboumi.1.rst @@ -0,0 +1,514 @@ +====================== +Biboumi(1) User Manual +====================== + +.. contents:: :depth: 2 + +NAME +==== + +biboumi - XMPP gateway to IRC + +Description +=========== + +Biboumi is an XMPP gateway that connects to IRC servers and translates +between the two protocols. It can be used to access IRC channels using any +XMPP client as if these channels were XMPP MUCs. + +Synopsis +======== + +biboumi [*config_filename*\ ] + +Options +======= + +Available command line options: + +config_filename +--------------- + +Specify the file to read for configuration. See *CONFIG* section for more +details on its content. + +Configuration +============= + +The configuration file uses a simple format of the form +``option=value``. Here is a description of each possible option: + +The configuration can be re-read at runtime (you can for example change the +log level without having to restart biboumi) by sending SIGUSR1 or SIGUSR2 +(see kill(1)) to the process. + +hostname +-------- + +Mandatory. The hostname served by the XMPP gateway. This domain must be +configured in the XMPP server as an external component. See the manual +for your XMPP server for more information. For prosody, see +http://prosody.im/doc/components#adding_an_external_component + +password +-------- + +Mandatory. The password used to authenticate the XMPP component to your +XMPP server. This password must be configured in the XMPP server, +associated with the external component on *hostname*. + +xmpp_server_ip +-------------- + +The IP address to connect to the XMPP server on. The connection to the +XMPP server is unencrypted, so the biboumi instance and the server should +normally be on the same host. The default value is 127.0.0.1. + +port +---- + +The TCP port to use to connect to the local XMPP component. The default +value is 5347. + +admin +----- + +The bare JID of the gateway administrator. This JID will have more +privileges than other standard users (the admin thus needs to check their +privileges), for example some administration ad-hoc commands will only be +available to that JID. + +fixed_irc_server +---------------- + +If this option contains the hostname of an IRC server (for example +irc.example.org), then biboumi will enforce the connexion to that IRC +server only. This means that a JID like "#chan@biboumi.example.com" must +be used instead of "#chan%irc.example.org@biboumi.example.com". In that +mode, the virtual channel (see `Connect to an IRC server`_) is not +available and you still need to use the ! separator to send message to an +IRC user (for example "foo!@biboumi.example.com" to send a message to +foo), although the in-room JID still work as expected +("#channel@biboumi.example.com/Nick"). On the other hand, the '%' lose +any meaning. It can appear in the JID but will not be interpreted as a +separator (thus the JID "#channel%hello@biboumi.example.com" points to the +channel named "#channel%hello" on the configured IRC server) This option +can for example be used by an administrator that just wants to let their +users join their own IRC server using an XMPP client, while forbidding +access to any other IRC server. + +realname_customization +---------------------- + +If this option is set to “false” (default is “true”), the users will not be +able to use the ad-hoc commands that lets them configure their realname and +username. + +realname_from_jid +----------------- + +If this option is set to “true”, the realname and username of each biboumi +user will be extracted from their JID. The realname is their bare JID, and +the username is the node-part of their JID. Note that if +``realname_customization`` is “true”, each user will still be able to +customize their realname and username, this option just decides the default +realname and username. + +If this option is set to “false” (the default value), the realname and +username of each user will be set to the nick they used to connect to the +IRC server. + +webirc_password +--------------- + +Configure a password to be communicated to the IRC server, as part of the +WEBIRC message (see https://kiwiirc.com/docs/webirc). If this option is +set, an additional DNS resolution of the hostname of each XMPP server will +be made when connecting to an IRC server. + +log_file +-------- + +A filename into which logs are written. If none is provided, the logs are +written on standard output. + +log_level +--------- + +Indicate what type of log messages to write in the logs. Value can be +from 0 to 3. 0 is debug, 1 is info, 2 is warning, 3 is error. The +default is 0, but a more practical value for production use is 1. + +ca_file +------- + +Specifies which file should be use as the list of trusted CA when +negociating a TLS session. By default this value is unset and biboumi +tries a list of well-known paths. + +outgoing_bind +------------- + +An address (IPv4 or IPv6) to bind the outgoing sockets to. If no value is +specified, it will use the one assigned by the operating system. You can +for example use outgoing_bind=192.168.1.11 to force biboumi to use the +interface with this address. Note that this is only used for connections +to IRC servers. + +Usage +===== + +Biboumi acts as a server, it should be run as a daemon that lives in the +background for as long as it is needed. Note that biboumi does not +daemonize itself, this task should be done by your init system (SysVinit, +systemd, upstart). + +When started, biboumi connects, without encryption (see `Security`_), to the +local XMPP server on the port ``5347`` and authenticates with the provided +password. Biboumi then serves the configured ``hostname``: this means that +all XMPP stanza with a `to` JID on that domain will be forwarded to biboumi +by the XMPP server, and biboumi will only send messages coming from that +hostname. + +When a user joins an IRC channel on an IRC server (see `Join an IRC +channel`_), biboumi connects to the remote IRC server, sets the user’s nick +as requested, and then tries to join the specified channel. If the same +user subsequently tries to connect to an other channel on the same server, +the same IRC connection is used. If, however, an other user wants to join +an IRC channel on that same IRC server, biboumi opens a new connection to +that server. Biboumi connects once to each IRC server, for each user on it. + +Additionally, if one user is using more than one clients (with the same bare +JID), they can join the same IRC channel (on the same server) behind one +single nickname. Biboumi will forward all the messages (the channel ones and +the private ones) and the presences to all the resources behind that nick. +There is no need to have multiple nicknames and multiple connections to be +able to take part in a conversation (or idle) in a channel from a mobile client +while the desktop client is still connected, for example. + +To cleanly shutdown the component, send a SIGINT or SIGTERM signal to it. +It will send messages to all connected IRC and XMPP servers to indicate a +reason why the users are being disconnected. Biboumi exits when the end of +communication is acknowledged by all IRC servers. If one or more IRC +servers do not respond, biboumi will only exit if it receives the same +signal again or if a 2 seconds delay has passed. + +Addressing +---------- + +IRC entities are represented by XMPP JIDs. The domain part of the JID is +the domain served by biboumi (the part after the ``@``, biboumi.example.com in +the examples), and the local part (the part before the ``@``) depends on the +concerned entity. + +IRC channels have a local part formed like this: +``channel_name`` % ``irc_server``. + +If the IRC channel you want to adress starts with the ``'#'`` character (or an +other character, announced by the IRC server, like ``'&'``, ``'+'`` or ``'!'``), +then you must include it in the JID. Some other gateway implementations, as +well as some IRC clients, do not require them to be started by one of these +characters, adding an implicit ``'#'`` in that case. Biboumi does not do that +because this gets confusing when trying to understand the difference between +the channels *#foo*, and *##foo*. Note that biboumi does not use the +presence of these special characters to identify an IRC channel, only the +presence of the separator `%` is used for that. + +The channel name can also be empty (for example ``%irc.example.com``), in that +case this represents the virtual channel provided by biboumi. See *Connect +to an IRC server* for more details. + +There is two ways to address an IRC user, using a local part like this: +``nickname`` ! ``irc_server`` +or by using the in-room address of the participant, like this: +``channel_name`` % ``irc_server`` @ ``biboumi.example.com`` / ``Nickname`` + +The second JID is available only to be compatible with XMPP clients when the +user wants to send a private message to the participant ``Nickname`` in the +room ``channel_name%irc_server@biboumi.example.com``. + +On XMPP, the node part of the JID can only be lowercase. On the other hand, +IRC nicknames are case-insensitive, this means that the nicknames toto, +Toto, tOtO and TOTO all represent the same IRC user. This means you can +talk to the user toto, and this will work. + +Also note that some IRC nicknames may contain characters that are not +allowed in the local part of a JID (for example '@'). If you need to send a +message to a nick containing such a character, you have to use a jid like +``%irc.example.com@biboumi.example.com/AnnoyingNickn@me``, because the JID +``AnnoyingNickn@me!irc.example.com@biboumi.example.com`` would not work. + +Examples: + +* ``#foo%irc.example.com@biboumi.example.com`` is the #foo IRC channel, on the + irc.example.com IRC server, and this is served by the biboumi instance on + biboumi.example.com + +* ``toto!irc.example.com@biboumi.example.com`` is the IRC user named toto, or + TotO, etc. + +* ``irc.example.com@biboumi.example.com`` is the IRC server irc.example.com. + +* ``%irc.example.com@biboumi.example.com`` is the virtual channel provided by + biboumi, for the IRC server irc.example.com. + +Note: Some JIDs are valid but make no sense in the context of +biboumi: + +* ``!irc.example.com@biboumi.example.com`` is the empty-string nick on the + irc.example.com server. It makes no sense to try to send messages to it. + +* ``#test%@biboumi.example.com``, or any other JID that does not contain an + IRC server is invalid. Any message to that kind of JID will trigger an + error, or will be ignored. + +If compiled with Libidn, an IRC channel participant has a bare JID +representing the “hostname” provided by the IRC server. This JID can only +be used to set IRC modes (for example to ban a user based on its IP), or to +identify user. It cannot be used to contact that user using biboumi. + +Join an IRC channel +------------------- + +To join an IRC channel ``#foo`` on the IRC server ``irc.example.com``, +join the XMPP MUC ``#foo%irc.example.com@biboumi.example.com``. + +Connect to an IRC server +------------------------ + +The connection to the IRC server is automatically made when the user tries +to join any channel on that IRC server. The connection is closed whenever +the last channel on that server is left by the user. To be able to stay +connected to an IRC server without having to be in a real IRC channel, +biboumi provides a virtual channel on the jid +``%irc.example.com@biboumi.example.com``. For example if you want to join the +channel ``#foo`` on the server ``irc.example.com``, but you need to authenticate +to a bot of the server before you’re allowed to join it, you can first join +the room ``%irc.example.com@biboumi.example.com`` (this will effectively +connect you to the IRC server without joining any room), then send your +authentication message to the user ``bot!irc.example.com@biboumi.example.com`` +and finally join the room ``#foo%irc.example.com@biboumi.example.com``. + +Channel messages +---------------- + +On XMPP, unlike on IRC, the displayed order of the messages is the same for +all participants of a MUC. Biboumi can not however provide this feature, as +it cannot know whether the IRC server has received and forwarded the +messages to other users. This means that the order of the messages +displayed in your XMPP client may not be the same than the order on other +IRC users’. + +List channels +------------- + +You can list the IRC channels on a given IRC server by sending an XMPP disco +items request on the IRC server JID. The number of channels on some servers +is huge, and biboumi does not (yet) support result set management (XEP 0059) +so the result stanza may be very big. + +Nicknames +--------- + +On IRC, nicknames are server-wide. This means that one user only has one +single nickname at one given time on all the channels of a server. This is +different from XMPP where a user can have a different nick on each MUC, +even if these MUCs are on the same server. + +This means that the nick you choose when joining your first IRC channel on a +given IRC server will be your nickname in all other channels that you join +on that same IRC server. +If you explicitely change your nickname on one channel, your nickname will +be changed on all channels on the same server as well. +Joining a new channel with a different nick, however, will not change your +nick. The provided nick will be ignored, in order to avoid changing your +nick on the whole server by mistake. If you want to have a different +nickname in the channel you’re going to join, you need to do it explicitly +with the NICK command before joining the channel. + +Private messages +---------------- + +Private messages are handled differently on IRC and on XMPP. On IRC, you +talk directly to one server-user: toto on the channel #foo is the same user +as toto on the channel #bar (as long as these two channels are on the same +IRC server). By default you will receive private messages from the “global” +user (aka nickname!irc.example.com@biboumi.example.com), unless you +previously sent a message to an in-room participant (something like +\#test%irc.example.com@biboumi.example.com/nickname), in which case future +messages from that same user will be received from that same “in-room” JID. + +Notices +------- + +Notices are received exactly like private messages. It is not possible to +send a notice. + +Kicks and bans +-------------- + +Kicks are transparently translated from one protocol to another. However +banning an XMPP participant has no effect. To ban an user you need to set a +mode +b on that user nick or host (see `IRC modes`_) and then kick it. + +Encoding +-------- + +On XMPP, the encoding is always ``UTF-8``, whereas on IRC the encoding of +each message can be anything. + +This means that biboumi has to convert everything coming from IRC into UTF-8 +without knowing the encoding of the received messages. To do so, it checks +if each message is UTF-8 valid, if not it tries to convert from +``iso_8859-1`` (because this appears to be the most common case, at least +on the channels I visit) to ``UTF-8``. If that conversion fails at some +point, a placeholder character ``'�'`` is inserted to indicate this +decoding error. + +Messages are always sent in UTF-8 over IRC, no conversion is done in that +direction. + +IRC modes +--------- + +One feature that doesn’t exist on XMPP but does on IRC is the ``modes``. +Although some of these modes have a correspondance in the XMPP world (for +example the ``+o`` mode on a user corresponds to the ``moderator`` role in +XMPP), it is impossible to map all these modes to an XMPP feature. To +circumvent this problem, biboumi provides a raw notification when modes are +changed, and lets the user change the modes directly. + +To change modes, simply send a message starting with “``/mode``” followed by +the modes and the arguments you want to send to the IRC server. For example +“/mode +aho louiz”. Note that your XMPP client may interprete messages +begining with “/” like a command. To actually send a message starting with +a slash, you may need to start your message with “//mode” or “/say /mode”, +depending on your client. + +When a mode is changed, the user is notified by a message coming from the +MUC bare JID, looking like “Mode #foo [+ov] [toto tutu]”. In addition, if +the mode change can be translated to an XMPP feature, the user will be +notified of this XMPP event as well. For example if a mode “+o toto” is +received, then toto’s role will be changed to moderator. The mapping +between IRC modes and XMPP features is as follow: + +``+q`` + Sets the participant’s role to ``moderator`` and its affiliation to ``owner``. + +``+a`` + Sets the participant’s role to ``moderator`` and its affiliation to ``owner``. + +``+o`` + Sets the participant’s role to ``moderator`` and its affiliation to ``admin``. + +``+h`` + Sets the participant’s role to ``moderator`` and its affiliation to ``member``. + +``+v`` + Sets the participant’s role to `participant` and its affiliation to ``member``. + +Similarly, when a biboumi user changes some participant's affiliation or role, biboumi translates that in an IRC mode change. + +Affiliation set to ``none`` + Sets mode to -vhoaq + +Affiliation set to ``member`` + Sets mode to +v-hoaq + +Role set to ``moderator`` + Sets mode to +h-oaq + +Affiliation set to ``admin`` + Sets mode to +o-aq + +Affiliation set to ``owner`` + Sets mode to +a-q + +Ad-hoc commands +--------------- + +Biboumi supports a few ad-hoc commands, as described in the XEP 0050. +Different ad-hoc commands are available for each JID type. + +On the gateway itself (e.g on the JID biboumi.example.com): +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +- ping: Just respond “pong” + +- hello: Provide a form, where the user enters their name, and biboumi + responds with a nice greeting. + +- disconnect-user: Only available to the administrator. The user provides + a list of JIDs, and a quit message. All the selected users are + disconnected from all the IRC servers to which they were connected, + using the provided quit message. Sending SIGINT to biboumi is equivalent + to using this command by selecting all the connected JIDs and using the + “Gateway shutdown” quit message, except that biboumi does not exit when + using this ad-hoc command. + +- disconnect-from-irc-servers: Disconnect a single user from one or more + IRC server. The user is immediately disconnected by closing the socket, + no message is sent to the IRC server, but the user is of course notified + with an XMPP message. The administrator can disconnect any user, while + the other users can only disconnect themselves. + +On a server JID (e.g on the JID chat.freenode.org@biboumi.example.com) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +- Configure: Lets each user configure some options that applies to the + concerned IRC server. + +On a channel JID (e.g on the JID #test%chat.freenode.org@biboumi.example.com) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +- Configure: Lets each user configure some options that applies to the + concerned IRC channel. Some of these options, if not configured for a + specific channel, defaults to the value configured at the IRC server + level. For example the encoding can be specified for both the channel + and the server. If an encoding is not specified for a channel, the + encoding configured in the server applies. + +Raw IRC messages +---------------- + +Biboumi tries to support as many IRC features as possible, but doesn’t +handle everything yet (or ever). In order to let the user send any +arbitrary IRC message, biboumi forwards any XMPP message received on an IRC +Server JID (see *ADDRESSING*) as a raw command to that IRC server. + +For example, to WHOIS the user Foo on the server irc.example.com, a user can +send the message “WHOIS Foo” to “irc.example.com@biboumi.example.com”. + +The message will be forwarded as is, without any modification appart from +adding "\r\n" at the end (to make it a valid IRC message). You need to have +a little bit of understanding of the IRC protocol to use this feature. + +Security +======== + +The connection to the XMPP server can only be made on localhost. The +XMPP server is not supposed to accept non-local connections from components. +Thus, encryption is not used to connect to the local XMPP server because it +is useless. + +If compiled with the Botan library, biboumi can use TLS when communicating +with the IRC serveres. It will first try ports 6697 and 6670 and use TLS if +it succeeds, if connection fails on both these ports, the connection is +established on port 6667 without any encryption. + +Biboumi does not check if the received JIDs are properly formatted using +nodeprep. This must be done by the XMPP server to which biboumi is directly +connected. + +Note if you use a biboumi that you have no control on: remember that the +administrator of the gateway you use is able to view all your IRC +conversations, whether you’re using encryption or not. This is exactly as +if you were running your IRC client on someone else’s server. Only use +biboumi if you trust its administrator (or, better, if you are the +administrator) or if you don’t intend to have any private conversation. + +Biboumi does not provide a way to ban users from connecting to it, has no +protection against flood or any sort of abuse that your users may cause on +the IRC servers. Some XMPP server however offer the possibility to restrict +what JID can access a gateway. Use that feature if you wish to grant access +to your biboumi instance only to a list of trusted users. diff --git a/docker/biboumi-test/debian/Dockerfile b/docker/biboumi-test/debian/Dockerfile new file mode 100644 index 0000000..f2a26ea --- /dev/null +++ b/docker/biboumi-test/debian/Dockerfile @@ -0,0 +1,72 @@ +# This Dockerfile creates a docker image suitable to run biboumi’s build and +# tests. For example, it can be used on with gitlab-ci. + +FROM docker.io/debian:latest + +RUN apt update + +# Needed to build biboumi +RUN apt install -y g++ +RUN apt install -y clang +RUN apt install -y valgrind +RUN apt install -y libc-ares-dev +RUN apt install -y libsqlite3-dev +RUN apt install -y libuuid1 +RUN apt install -y cmake +RUN apt install -y make +RUN apt install -y libexpat1-dev +RUN apt install -y libidn11-dev +RUN apt install -y uuid-dev +RUN apt install -y libsystemd-dev +RUN apt install -y pandoc + +# Needed to run tests +RUN apt install -y git +RUN apt install -y python3-lxml +RUN apt install -y lcov + +# Install botan +RUN git clone https://github.com/randombit/botan.git +RUN cd botan && ./configure.py --prefix=/usr && make -j8 && make install +RUN rm -rf /botan + +# Install litesql +RUN git clone git://git.louiz.org/litesql +RUN mkdir /litesql/build && cd /litesql/build && cmake .. -DCMAKE_INSTALL_PREFIX=/usr && make -j8 +RUN cd /litesql/build && make install +RUN rm -rf /litesql + +RUN ldconfig + +# Install slixmpp, for e2e tests +RUN apt install -y python3-pip +RUN git clone git://git.louiz.org/slixmpp +RUN pip3 install pyasn1 +RUN apt install -y python3-dev +RUN cd slixmpp && python3 setup.py build && python3 setup.py install + +RUN useradd tester -m + +# Install charybdis, for e2e tests +RUN apt install -y automake autoconf flex bison libltdl-dev openssl +RUN apt install -y libtool +RUN git clone https://github.com/charybdis-ircd/charybdis.git && cd charybdis +RUN cd /charybdis && ./autogen.sh && ./configure --prefix=/home/tester/ircd --bindir=/usr/bin && make -j8 && make install +RUN chown -R tester:tester /home/tester/ircd +RUN rm -rf /charybdis + +RUN apt install -y locales +RUN export LANGUAGE=en_US.UTF-8 +RUN export LANG=en_US.UTF-8 +RUN export LC_ALL=en_US.UTF-8 +RUN locale-gen +RUN dpkg-reconfigure locales + +RUN dpkg-reconfigure locales && \ + locale-gen C.UTF-8 && \ + /usr/sbin/update-locale LANG=C.UTF-8 + +ENV LC_ALL C.UTF-8 + +WORKDIR /home/tester +USER tester diff --git a/docker/biboumi-test/fedora/Dockerfile b/docker/biboumi-test/fedora/Dockerfile new file mode 100644 index 0000000..3c48645 --- /dev/null +++ b/docker/biboumi-test/fedora/Dockerfile @@ -0,0 +1,64 @@ +# This Dockerfile creates a docker image suitable to run biboumi’s build and +# tests. For example, it can be used on with gitlab-ci. + +FROM docker.io/fedora:latest + +RUN dnf update -y + +# Needed to build biboumi +RUN dnf install -y gcc-c++ +RUN dnf install -y clang +RUN dnf install -y valgrind +RUN dnf install -y c-ares-devel +RUN dnf install -y sqlite-devel +RUN dnf install -y libuuid-devel +RUN dnf install -y cmake +RUN dnf install -y make +RUN dnf install -y expat-devel +RUN dnf install -y libidn-devel +RUN dnf install -y uuid-devel +RUN dnf install -y systemd-devel +RUN dnf install -y pandoc + +# Needed to run tests +RUN dnf install -y git +RUN dnf install -y fedora-packager python3-lxml +RUN dnf install -y lcov + +# To be able to create the RPM +RUN dnf install -y rpmdevtools + +# Install botan +RUN git clone https://github.com/randombit/botan.git +RUN cd botan && ./configure.py --prefix=/usr && make -j8 && make install +RUN rm -rf /botan + +# Install litesql +RUN git clone git://git.louiz.org/litesql +RUN mkdir /litesql/build && cd /litesql/build && cmake .. -DCMAKE_INSTALL_PREFIX=/usr && make -j8 +RUN cd /litesql/build && make install +RUN rm -rf /litesql + +RUN ldconfig + +# Install slixmpp, for e2e tests +RUN git clone git://git.louiz.org/slixmpp +RUN pip3 install pyasn1 +RUN dnf install -y python3-devel +RUN cd slixmpp && python3 setup.py build && python3 setup.py install + +RUN useradd tester + +# Install charybdis, for e2e tests +RUN dnf install -y automake autoconf flex flex-devel bison libtool-ltdl-devel openssl-devel +RUN dnf install -y libtool +RUN git clone https://github.com/charybdis-ircd/charybdis.git && cd charybdis +RUN cd /charybdis && ./autogen.sh && ./configure --prefix=/home/tester/ircd --bindir=/usr/bin && make -j8 && make install +RUN chown -R tester:tester /home/tester/ircd +RUN rm -rf /charybdis + +RUN su - tester -c "echo export LANG=en_GB.utf-8 >> /home/tester/.bashrc" + +WORKDIR /home/tester +USER tester + diff --git a/louloulibs/CMakeLists.txt b/louloulibs/CMakeLists.txt new file mode 100644 index 0000000..bf53504 --- /dev/null +++ b/louloulibs/CMakeLists.txt @@ -0,0 +1,146 @@ +cmake_minimum_required(VERSION 2.6) + +set(${PROJECT_NAME}_VERSION_MAJOR 1) +set(${PROJECT_NAME}_VERSION_MINOR 0) +set(${PROJECT_NAME}_VERSION_SUFFIX "~dev") + +set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -std=c++1y -pedantic -Wall -Wextra") + +# Define a __FILENAME__ macro to get the filename of each file, instead of +# the full path as in __FILE__ +set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -D__FILENAME__='\"$(subst ${CMAKE_SOURCE_DIR}/,,$(abspath $<))\"'") + +# +## Look for external libraries +# +set(CMAKE_MODULE_PATH ${CMAKE_MODULE_PATH} "${CMAKE_CURRENT_SOURCE_DIR}/cmake/Modules/") +include(FindEXPAT) +find_package(EXPAT REQUIRED) +find_package(ICONV REQUIRED) +find_package(LIBUUID REQUIRED) + +if(WITH_LIBIDN) + find_package(LIBIDN REQUIRED) +elseif(NOT WITHOUT_LIBIDN) + find_package(LIBIDN) +endif() + +if(WITH_SYSTEMD) + find_package(SYSTEMD REQUIRED) +elseif(NOT WITHOUT_SYSTEMD) + find_package(SYSTEMD) +endif() + +if(WITH_BOTAN) + find_package(BOTAN REQUIRED) +elseif(NOT WITHOUT_BOTAN) + find_package(BOTAN) +endif() + +if(WITH_CARES) + find_package(CARES REQUIRED) +elseif(NOT WITHOUT_CARES) + find_package(CARES) +endif() + +# To be able to include the config.h file generated by cmake +include_directories("${CMAKE_CURRENT_BINARY_DIR}") +include_directories("${CMAKE_CURRENT_SOURCE_DIR}") +include_directories(${EXPAT_INCLUDE_DIRS}) +include_directories(${ICONV_INCLUDE_DIRS}) +include_directories(${LIBUUID_INCLUDE_DIRS}) + +set(EXPAT_INCLUDE_DIRS ${EXPAT_INCLUDE_DIRS} PARENT_SCOPE) +set(ICONV_INCLUDE_DIRS ${ICONV_INCLUDE_DIRS} PARENT_SCOPE) +set(LIBUUID_INCLUDE_DIRS ${LIBUUID_INCLUDE_DIRS} PARENT_SCOPE) + +if(LIBIDN_FOUND) + include_directories(${LIBIDN_INCLUDE_DIRS}) + set(LIBDIN_FOUND ${LIBDIN_FOUND} PARENT_SCOPE) + set(LIBDIN_INCLUDE_DIRS ${LIBDIN_INCLUDE_DIRS} PARENT_SCOPE) +endif() + +if(SYSTEMD_FOUND) + include_directories(${SYSTEMD_INCLUDE_DIRS}) + set(SYSTEMD_FOUND ${SYSTEMD_FOUND} PARENT_SCOPE) + set(SYSTEMD_INCLUDE_DIRS ${SYSTEMD_INCLUDE_DIRS} PARENT_SCOPE) +endif() + +if(BOTAN_FOUND) + include_directories(SYSTEM ${BOTAN_INCLUDE_DIRS}) + set(BOTAN_FOUND ${BOTAN_FOUND} PARENT_SCOPE) + set(BOTAN_INCLUDE_DIRS ${BOTAN_INCLUDE_DIRS} PARENT_SCOPE) +endif() + +if(CARES_FOUND) + include_directories(${CARES_INCLUDE_DIRS}) + set(CARES_FOUND ${CARES_FOUND} PARENT_SCOPE) + set(CARES_INCLUDE_DIRS ${CARES_INCLUDE_DIRS} PARENT_SCOPE) +endif() + +set(POLLER_DOCSTRING "Choose the poller between POLL and EPOLL (Linux-only)") +if(${CMAKE_SYSTEM_NAME} MATCHES "Linux") + set(POLLER "EPOLL" CACHE STRING ${POLLER_DOCSTRING}) +else() + set(POLLER "POLL" CACHE STRING ${POLLER_DOCSTRING}) +endif() +if((NOT ${POLLER} MATCHES "POLL") AND + (NOT ${POLLER} MATCHES "EPOLL")) + message(FATAL_ERROR "POLLER must be either POLL or EPOLL") +endif() + +# +## utils +# +file(GLOB source_utils + utils/*.[hc]pp) +add_library(utils STATIC ${source_utils}) +target_link_libraries(utils ${ICONV_LIBRARIES}) + +# +## config +# +file(GLOB source_config + config/*.[hc]pp) +add_library(config STATIC ${source_config}) +target_link_libraries(config utils) + +# +## logger +# +file(GLOB source_logger + logger/*.[hc]pp) +add_library(logger STATIC ${source_logger}) +target_link_libraries(logger config) + +# +## network +# +file(GLOB source_network + network/*.[hc]pp) +add_library(network STATIC ${source_network}) +target_link_libraries(network logger) +if(BOTAN_FOUND) + target_link_libraries(network ${BOTAN_LIBRARIES}) +endif() +if(CARES_FOUND) + target_link_libraries(network ${CARES_LIBRARIES}) +endif() + +# +## xmpplib +# +file(GLOB source_xmpplib + xmpp/*.[hc]pp) +add_library(xmpplib STATIC ${source_xmpplib}) +target_link_libraries(xmpplib network utils logger + ${EXPAT_LIBRARIES} + ${LIBUUID_LIBRARIES}) +if(LIBIDN_FOUND) + target_link_libraries(xmpplib ${LIBIDN_LIBRARIES}) +endif() +if(SYSTEMD_FOUND) + target_link_libraries(xmpplib ${SYSTEMD_LIBRARIES}) +endif() + +configure_file(${CMAKE_CURRENT_SOURCE_DIR}/louloulibs.h.cmake ${CMAKE_BINARY_DIR}/src/louloulibs.h) diff --git a/louloulibs/cmake/Modules/FindBOTAN.cmake b/louloulibs/cmake/Modules/FindBOTAN.cmake new file mode 100644 index 0000000..a12bd35 --- /dev/null +++ b/louloulibs/cmake/Modules/FindBOTAN.cmake @@ -0,0 +1,35 @@ +# - Find botan +# Find the botan cryptographic library +# +# This module defines the following variables: +# BOTAN_FOUND - True if library and include directory are found +# If set to TRUE, the following are also defined: +# BOTAN_INCLUDE_DIRS - The directory where to find the header file +# BOTAN_LIBRARIES - Where to find the library file +# +# For conveniance, these variables are also set. They have the same values +# than the variables above. The user can thus choose his/her prefered way +# to write them. +# BOTAN_LIBRARY +# BOTAN_INCLUDE_DIR +# +# This file is in the public domain + +find_path(BOTAN_INCLUDE_DIRS NAMES botan/botan.h + PATH_SUFFIXES botan-1.11 + DOC "The botan include directory") + +find_library(BOTAN_LIBRARIES NAMES botan botan-1.11 + DOC "The botan library") + +# Use some standard module to handle the QUIETLY and REQUIRED arguments, and +# set BOTAN_FOUND to TRUE if these two variables are set. +include(FindPackageHandleStandardArgs) +find_package_handle_standard_args(BOTAN REQUIRED_VARS BOTAN_LIBRARIES BOTAN_INCLUDE_DIRS) + +if(BOTAN_FOUND) + set(BOTAN_LIBRARY ${BOTAN_LIBRARIES}) + set(BOTAN_INCLUDE_DIR ${BOTAN_INCLUDE_DIRS}) +endif() + +mark_as_advanced(BOTAN_INCLUDE_DIRS BOTAN_LIBRARIES) diff --git a/louloulibs/cmake/Modules/FindCARES.cmake b/louloulibs/cmake/Modules/FindCARES.cmake new file mode 100644 index 0000000..c4c757a --- /dev/null +++ b/louloulibs/cmake/Modules/FindCARES.cmake @@ -0,0 +1,37 @@ +# - Find c-ares +# Find the c-ares library, and more particularly the stringprep header. +# +# This module defines the following variables: +# CARES_FOUND - True if library and include directory are found +# If set to TRUE, the following are also defined: +# CARES_INCLUDE_DIRS - The directory where to find the header file +# CARES_LIBRARIES - Where to find the library file +# +# For conveniance, these variables are also set. They have the same values +# than the variables above. The user can thus choose his/her prefered way +# to write them. +# CARES_INCLUDE_DIR +# CARES_LIBRARY +# +# This file is in the public domain + +if(NOT CARES_FOUND) + find_path(CARES_INCLUDE_DIRS NAMES ares.h + DOC "The c-ares include directory") + + find_library(CARES_LIBRARIES NAMES cares + DOC "The c-ares library") + + # Use some standard module to handle the QUIETLY and REQUIRED arguments, and + # set CARES_FOUND to TRUE if these two variables are set. + include(FindPackageHandleStandardArgs) + find_package_handle_standard_args(CARES REQUIRED_VARS CARES_LIBRARIES CARES_INCLUDE_DIRS) + + # Compatibility for all the ways of writing these variables + if(CARES_FOUND) + set(CARES_INCLUDE_DIR ${CARES_INCLUDE_DIRS}) + set(CARES_LIBRARY ${CARES_LIBRARIES}) + endif() +endif() + +mark_as_advanced(CARES_INCLUDE_DIRS CARES_LIBRARIES) diff --git a/louloulibs/cmake/Modules/FindICONV.cmake b/louloulibs/cmake/Modules/FindICONV.cmake new file mode 100644 index 0000000..7ca173f --- /dev/null +++ b/louloulibs/cmake/Modules/FindICONV.cmake @@ -0,0 +1,60 @@ +# - Find iconv +# Find the iconv (character set conversion) library +# +# This module defines the following variables: +# ICONV_FOUND - True if library and include directory are found +# If set to TRUE, the following are also defined: +# ICONV_INCLUDE_DIRS - The directory where to find the header file +# ICONV_LIBRARIES - Where to find the library file +# ICONV_SECOND_ARGUMENT_IS_CONST - The second argument for iconv() is const +# +# For conveniance, these variables are also set. They have the same values +# than the variables above. The user can thus choose his/her prefered way +# to write them. +# ICONV_LIBRARY +# ICONV_INCLUDE_DIR +# +# This file is in the public domain + +find_path(ICONV_INCLUDE_DIRS NAMES iconv.h + DOC "The iconv include directory") + +find_library(ICONV_LIBRARIES NAMES iconv libiconv libiconv-2 c + DOC "The iconv library") + +# Use some standard module to handle the QUIETLY and REQUIRED arguments, and +# set ICONV_FOUND to TRUE if these two variables are set. +include(FindPackageHandleStandardArgs) +find_package_handle_standard_args(Iconv REQUIRED_VARS ICONV_LIBRARIES ICONV_INCLUDE_DIRS) + +# Check if the prototype is +# size_t iconv(iconv_t cd, char** inbuf, size_t* inbytesleft, +# char** outbuf, size_t* outbytesleft); +# or +# size_t iconv (iconv_t cd, const char** inbuf, size_t* inbytesleft, +# char** outbuf, size_t* outbytesleft); +if(ICONV_FOUND) + include(CheckCXXSourceCompiles) + + # Set the parameters needed to compile the following code. + set(CMAKE_REQUIRED_INCLUDES ${ICONV_INCLUDE_DIRS}) + set(CMAKE_REQUIRED_LIBRARIES ${ICONV_LIBRARIES}) + + check_cxx_source_compiles(" + #include <iconv.h> + int main(){ + iconv_t conv = 0; + const char* in = 0; + size_t ilen = 0; + char* out = 0; + size_t olen = 0; + iconv(conv, &in, &ilen, &out, &olen); + return 0;}" + ICONV_SECOND_ARGUMENT_IS_CONST) + +# Compatibility for all the ways of writing these variables + set(ICONV_LIBRARY ${ICONV_LIBRARIES}) + set(ICONV_INCLUDE_DIR ${ICONV_INCLUDE_DIRS}) +endif() + +mark_as_advanced(ICONV_INCLUDE_DIRS ICONV_LIBRARIES ICONV_SECOND_ARGUMENT_IS_CONST)
\ No newline at end of file diff --git a/louloulibs/cmake/Modules/FindLIBIDN.cmake b/louloulibs/cmake/Modules/FindLIBIDN.cmake new file mode 100644 index 0000000..611a6a8 --- /dev/null +++ b/louloulibs/cmake/Modules/FindLIBIDN.cmake @@ -0,0 +1,41 @@ +# - Find libidn +# Find the libidn library, and more particularly the stringprep header. +# +# This module defines the following variables: +# LIBIDN_FOUND - True if library and include directory are found +# If set to TRUE, the following are also defined: +# LIBIDN_INCLUDE_DIRS - The directory where to find the header file +# LIBIDN_LIBRARIES - Where to find the library file +# +# For conveniance, these variables are also set. They have the same values +# than the variables above. The user can thus choose his/her prefered way +# to write them. +# LIBIDN_INCLUDE_DIR +# LIBIDN_LIBRARY +# +# This file is in the public domain + +include(FindPkgConfig) +pkg_check_modules(LIBIDN libidn) + +if(NOT LIBIDN_FOUND) + find_path(LIBIDN_INCLUDE_DIRS NAMES stringprep.h + DOC "The libidn include directory") + + # The library containing the stringprep module is libidn + find_library(LIBIDN_LIBRARIES NAMES idn + DOC "The libidn library") + + # Use some standard module to handle the QUIETLY and REQUIRED arguments, and + # set LIBIDN_FOUND to TRUE if these two variables are set. + include(FindPackageHandleStandardArgs) + find_package_handle_standard_args(LIBIDN REQUIRED_VARS LIBIDN_LIBRARIES LIBIDN_INCLUDE_DIRS) + + # Compatibility for all the ways of writing these variables + if(LIBIDN_FOUND) + set(LIBIDN_INCLUDE_DIR ${LIBIDN_INCLUDE_DIRS}) + set(LIBIDN_LIBRARY ${LIBIDN_LIBRARIES}) + endif() +endif() + +mark_as_advanced(LIBIDN_INCLUDE_DIRS LIBIDN_LIBRARIES)
\ No newline at end of file diff --git a/louloulibs/cmake/Modules/FindLIBUUID.cmake b/louloulibs/cmake/Modules/FindLIBUUID.cmake new file mode 100644 index 0000000..17d3c42 --- /dev/null +++ b/louloulibs/cmake/Modules/FindLIBUUID.cmake @@ -0,0 +1,41 @@ +# - Find libuuid +# Find the libuuid library +# +# This module defines the following variables: +# LIBUUID_FOUND - True if library and include directory are found +# If set to TRUE, the following are also defined: +# LIBUUID_INCLUDE_DIRS - The directory where to find the header file +# LIBUUID_LIBRARIES - Where to find the library file +# +# For conveniance, these variables are also set. They have the same values +# than the variables above. The user can thus choose his/her prefered way +# to write them. +# LIBUUID_INCLUDE_DIR +# LIBUUID_LIBRARY +# +# This file is in the public domain + +include(FindPkgConfig) +pkg_check_modules(LIBUUID uuid) + +if(NOT LIBUUID_FOUND) + find_path(LIBUUID_INCLUDE_DIRS NAMES uuid.h + PATH_SUFFIXES uuid + DOC "The libuuid include directory") + + find_library(LIBUUID_LIBRARIES NAMES uuid + DOC "The libuuid library") + + # Use some standard module to handle the QUIETLY and REQUIRED arguments, and + # set LIBUUID_FOUND to TRUE if these two variables are set. + include(FindPackageHandleStandardArgs) + find_package_handle_standard_args(LIBUUID REQUIRED_VARS LIBUUID_LIBRARIES LIBUUID_INCLUDE_DIRS) + + # Compatibility for all the ways of writing these variables + if(LIBUUID_FOUND) + set(LIBUUID_INCLUDE_DIR ${LIBUUID_INCLUDE_DIRS}) + set(LIBUUID_LIBRARY ${LIBUUID_LIBRARIES}) + endif() +endif() + +mark_as_advanced(LIBUUID_INCLUDE_DIRS LIBUUID_LIBRARIES) diff --git a/louloulibs/cmake/Modules/FindSYSTEMD.cmake b/louloulibs/cmake/Modules/FindSYSTEMD.cmake new file mode 100644 index 0000000..c7decde --- /dev/null +++ b/louloulibs/cmake/Modules/FindSYSTEMD.cmake @@ -0,0 +1,39 @@ +# - Find SystemdDaemon +# Find the systemd daemon library +# +# This module defines the following variables: +# SYSTEMD_FOUND - True if library and include directory are found +# If set to TRUE, the following are also defined: +# SYSTEMD_INCLUDE_DIRS - The directory where to find the header file +# SYSTEMD_LIBRARIES - Where to find the library file +# +# For conveniance, these variables are also set. They have the same values +# than the variables above. The user can thus choose his/her prefered way +# to write them. +# SYSTEMD_LIBRARY +# SYSTEMD_INCLUDE_DIR +# +# This file is in the public domain + +include(FindPkgConfig) +pkg_check_modules(SYSTEMD libsystemd) + +if(NOT SYSTEMD_FOUND) + find_path(SYSTEMD_INCLUDE_DIRS NAMES systemd/sd-daemon.h + DOC "The Systemd include directory") + + find_library(SYSTEMD_LIBRARIES NAMES systemd + DOC "The Systemd library") + + # Use some standard module to handle the QUIETLY and REQUIRED arguments, and + # set SYSTEMD_FOUND to TRUE if these two variables are set. + include(FindPackageHandleStandardArgs) + find_package_handle_standard_args(SYSTEMD REQUIRED_VARS SYSTEMD_LIBRARIES SYSTEMD_INCLUDE_DIRS) + + if(SYSTEMD_FOUND) + set(SYSTEMD_LIBRARY ${SYSTEMD_LIBRARIES}) + set(SYSTEMD_INCLUDE_DIR ${SYSTEMD_INCLUDE_DIRS}) + endif() +endif() + +mark_as_advanced(SYSTEMD_INCLUDE_DIRS SYSTEMD_LIBRARIES)
\ No newline at end of file diff --git a/louloulibs/config/config.cpp b/louloulibs/config/config.cpp new file mode 100644 index 0000000..417981d --- /dev/null +++ b/louloulibs/config/config.cpp @@ -0,0 +1,104 @@ +#include <config/config.hpp> +#include <logger/logger.hpp> + +#include <cstring> +#include <sstream> + +#include <cstdlib> + +std::string Config::filename{}; +std::map<std::string, std::string> Config::values{}; +std::vector<t_config_changed_callback> Config::callbacks{}; + +std::string Config::get(const std::string& option, const std::string& def) +{ + auto it = Config::values.find(option); + + if (it == Config::values.end()) + return def; + return it->second; +} + +int Config::get_int(const std::string& option, const int& def) +{ + std::string res = Config::get(option, ""); + if (!res.empty()) + return std::atoi(res.c_str()); + else + return def; +} + +void Config::set(const std::string& option, const std::string& value, bool save) +{ + Config::values[option] = value; + if (save) + { + Config::save_to_file(); + Config::trigger_configuration_change(); + } +} + +void Config::connect(t_config_changed_callback callback) +{ + Config::callbacks.push_back(callback); +} + +void Config::clear() +{ + Config::values.clear(); +} + +/** + * Private methods + */ +void Config::trigger_configuration_change() +{ + std::vector<t_config_changed_callback>::iterator it; + for (it = Config::callbacks.begin(); it < Config::callbacks.end(); ++it) + (*it)(); +} + +bool Config::read_conf(const std::string& name) +{ + if (!name.empty()) + Config::filename = name; + + std::ifstream file(Config::filename.data()); + if (!file.is_open()) + { + log_error("Error while opening file ", filename, " for reading: ", strerror(errno)); + return false; + } + + Config::clear(); + + std::string line; + size_t pos; + std::string option; + std::string value; + while (file.good()) + { + std::getline(file, line); + if (line == "" || line[0] == '#') + continue ; + pos = line.find('='); + if (pos == std::string::npos) + continue ; + option = line.substr(0, pos); + value = line.substr(pos+1); + Config::values[option] = value; + } + return true; +} + +void Config::save_to_file() +{ + std::ofstream file(Config::filename.data()); + if (file.fail()) + { + log_error("Could not save config file."); + return ; + } + for (const auto& it: Config::values) + file << it.first << "=" << it.second << '\n'; +} diff --git a/louloulibs/config/config.hpp b/louloulibs/config/config.hpp new file mode 100644 index 0000000..6728df8 --- /dev/null +++ b/louloulibs/config/config.hpp @@ -0,0 +1,94 @@ +/** + * Read the config file and save all the values in a map. + * Also, a singleton. + * + * Use Config::filename = "bla" to set the filename you want to use. + * + * If you want to exit if the file does not exist when it is open for + * reading, set Config::file_must_exist = true. + * + * Config::get() can then be used to access the values in the conf. + * + * Use Config::close() when you're done getting/setting value. This will + * save the config into the file. + */ + +#pragma once + + +#include <functional> +#include <fstream> +#include <memory> +#include <vector> +#include <string> +#include <map> + +typedef std::function<void()> t_config_changed_callback; + +class Config +{ +public: + Config() = default; + ~Config() = default; + Config(const Config&) = delete; + Config& operator=(const Config&) = delete; + Config(Config&&) = delete; + Config& operator=(Config&&) = delete; + + /** + * returns a value from the config. If it doesn’t exist, use + * the second argument as the default. + */ + static std::string get(const std::string&, const std::string&); + /** + * returns a value from the config. If it doesn’t exist, use + * the second argument as the default. + */ + static int get_int(const std::string&, const int&); + /** + * Set a value for the given option. And write all the config + * in the file from which it was read if save is true. + */ + static void set(const std::string&, const std::string&, bool save = false); + /** + * Adds a function to a list. This function will be called whenever a + * configuration change occurs (when set() is called, or when the initial + * conf is read) + */ + static void connect(t_config_changed_callback); + /** + * Destroy the instance, forcing it to be recreated (with potentially + * different parameters) the next time it’s needed. + */ + static void clear(); + /** + * Read the configuration file at the given path. + */ + static bool read_conf(const std::string& name=""); + /** + * Get the filename + */ + static const std::string& get_filename() + { return Config::filename; } + +private: + /** + * Set the value of the filename to use, before calling any method. + */ + static std::string filename; + /** + * Write all the config values into the configuration file + */ + static void save_to_file(); + /** + * Call all the callbacks previously registered using connect(). + * This is used to notify any class that a configuration change occured. + */ + static void trigger_configuration_change(); + + static std::map<std::string, std::string> values; + static std::vector<t_config_changed_callback> callbacks; + +}; + + diff --git a/louloulibs/logger/logger.cpp b/louloulibs/logger/logger.cpp new file mode 100644 index 0000000..7336579 --- /dev/null +++ b/louloulibs/logger/logger.cpp @@ -0,0 +1,38 @@ +#include <logger/logger.hpp> +#include <config/config.hpp> + +Logger::Logger(const int log_level): + log_level(log_level), + stream(std::cout.rdbuf()) +{ +} + +Logger::Logger(const int log_level, const std::string& log_file): + log_level(log_level), + ofstream(log_file.data(), std::ios_base::app), + stream(ofstream.rdbuf()) +{ +} + +std::unique_ptr<Logger>& Logger::instance() +{ + static std::unique_ptr<Logger> instance; + + if (!instance) + { + const std::string log_file = Config::get("log_file", ""); + const int log_level = Config::get_int("log_level", 0); + if (log_file.empty()) + instance = std::make_unique<Logger>(log_level); + else + instance = std::make_unique<Logger>(log_level, log_file); + } + return instance; +} + +std::ostream& Logger::get_stream(const int lvl) +{ + if (lvl >= this->log_level) + return this->stream; + return this->null_stream; +} diff --git a/louloulibs/logger/logger.hpp b/louloulibs/logger/logger.hpp new file mode 100644 index 0000000..0893c77 --- /dev/null +++ b/louloulibs/logger/logger.hpp @@ -0,0 +1,126 @@ +#pragma once + + +/** + * Singleton used in logger macros to write into files or stdout, with + * various levels of severity. + * Only the macros should be used. + * @class Logger + */ + +#include <memory> +#include <iostream> +#include <fstream> + +#define debug_lvl 0 +#define info_lvl 1 +#define warning_lvl 2 +#define error_lvl 3 + +#include "louloulibs.h" +#ifdef SYSTEMD_FOUND +# include <systemd/sd-daemon.h> +#else +# define SD_DEBUG "[DEBUG]: " +# define SD_INFO "[INFO]: " +# define SD_WARNING "[WARNING]: " +# define SD_ERR "[ERROR]: " +#endif + +// Macro defined to get the filename instead of the full path. But if it is +// not properly defined by the build system, we fallback to __FILE__ +#ifndef __FILENAME__ +# define __FILENAME__ __FILE__ +#endif + +/** + * Juste a structure representing a stream doing nothing with its input. + */ +class nullstream: public std::ostream +{ +public: + nullstream(): + std::ostream(0) + { } +}; + +class Logger +{ +public: + static std::unique_ptr<Logger>& instance(); + std::ostream& get_stream(const int); + Logger(const int log_level, const std::string& log_file); + Logger(const int log_level); + + Logger(const Logger&) = delete; + Logger& operator=(const Logger&) = delete; + Logger(Logger&&) = delete; + Logger& operator=(Logger&&) = delete; + +private: + const int log_level; + std::ofstream ofstream; + nullstream null_stream; + std::ostream stream; +}; + +#define WHERE __FILENAME__, ":", __LINE__, ":\t" + +namespace logging_details +{ + template <typename T> + void log(std::ostream& os, const T& arg) + { + os << arg << std::endl; + } + + template <typename T, typename... U> + void log(std::ostream& os, const T& first, U&&... rest) + { + os << first; + log(os, std::forward<U>(rest)...); + } + + template <typename... U> + void log_debug(U&&... args) + { + auto& os = Logger::instance()->get_stream(debug_lvl); + os << SD_DEBUG; + log(os, std::forward<U>(args)...); + } + + template <typename... U> + void log_info(U&&... args) + { + auto& os = Logger::instance()->get_stream(info_lvl); + os << SD_INFO; + log(os, std::forward<U>(args)...); + } + + template <typename... U> + void log_warning(U&&... args) + { + auto& os = Logger::instance()->get_stream(warning_lvl); + os << SD_WARNING; + log(os, std::forward<U>(args)...); + } + + template <typename... U> + void log_error(U&&... args) + { + auto& os = Logger::instance()->get_stream(error_lvl); + os << SD_ERR; + log(os, std::forward<U>(args)...); + } +} + +#define log_info(...) logging_details::log_info(WHERE, __VA_ARGS__) + +#define log_warning(...) logging_details::log_warning(WHERE, __VA_ARGS__) + +#define log_error(...) logging_details::log_error(WHERE, __VA_ARGS__) + +#define log_debug(...) logging_details::log_debug(WHERE, __VA_ARGS__) + + + diff --git a/louloulibs/louloulibs.h.cmake b/louloulibs/louloulibs.h.cmake new file mode 100644 index 0000000..2feaf4e --- /dev/null +++ b/louloulibs/louloulibs.h.cmake @@ -0,0 +1,9 @@ +#define SYSTEM_NAME "${CMAKE_SYSTEM}" +#cmakedefine ICONV_SECOND_ARGUMENT_IS_CONST +#cmakedefine LIBIDN_FOUND +#cmakedefine SYSTEMD_FOUND +#cmakedefine POLLER ${POLLER} +#cmakedefine BOTAN_FOUND +#cmakedefine CARES_FOUND +#cmakedefine SOFTWARE_VERSION "${SOFTWARE_VERSION}" +#cmakedefine PROJECT_NAME "${PROJECT_NAME}"
\ No newline at end of file diff --git a/louloulibs/network/credentials_manager.cpp b/louloulibs/network/credentials_manager.cpp new file mode 100644 index 0000000..ee83c3b --- /dev/null +++ b/louloulibs/network/credentials_manager.cpp @@ -0,0 +1,116 @@ +#include "louloulibs.h" + +#ifdef BOTAN_FOUND +#include <network/tcp_socket_handler.hpp> +#include <network/credentials_manager.hpp> +#include <logger/logger.hpp> +#include <botan/tls_exceptn.h> +#include <config/config.hpp> + +#ifdef USE_DATABASE +# include <database/database.hpp> +#endif + +/** + * TODO find a standard way to find that out. + */ +static const std::vector<std::string> default_cert_files = { + "/etc/ssl/certs/ca-bundle.crt", + "/etc/pki/tls/certs/ca-bundle.crt", + "/etc/ssl/certs/ca-certificates.crt", + "/etc/ca-certificates/extracted/tls-ca-bundle.pem" +}; + +Botan::Certificate_Store_In_Memory BasicCredentialsManager::certificate_store; +bool BasicCredentialsManager::certs_loaded = false; + +BasicCredentialsManager::BasicCredentialsManager(const TCPSocketHandler* const socket_handler): + Botan::Credentials_Manager(), + socket_handler(socket_handler), + trusted_fingerprint{} +{ + this->load_certs(); +} + +void BasicCredentialsManager::set_trusted_fingerprint(const std::string& fingerprint) +{ + this->trusted_fingerprint = fingerprint; +} + +void BasicCredentialsManager::verify_certificate_chain(const std::string& type, + const std::string& purported_hostname, + const std::vector<Botan::X509_Certificate>& certs) +{ + log_debug("Checking remote certificate (", type, ") for hostname ", purported_hostname); + try + { + Botan::Credentials_Manager::verify_certificate_chain(type, purported_hostname, certs); + log_debug("Certificate is valid"); + } + catch (const std::exception& tls_exception) + { + log_warning("TLS certificate check failed: ", tls_exception.what()); + if (!this->trusted_fingerprint.empty() && !certs.empty() && + this->trusted_fingerprint == certs[0].fingerprint() && + certs[0].matches_dns_name(purported_hostname)) + // We trust the certificate, based on the trusted fingerprint and + // the fact that the hostname matches + return; + + if (this->socket_handler->abort_on_invalid_cert()) + throw; + } +} + +void BasicCredentialsManager::load_certs() +{ + // Only load the certificates the first time + if (BasicCredentialsManager::certs_loaded) + return; + const std::string conf_path = Config::get("ca_file", ""); + std::vector<std::string> paths; + if (conf_path.empty()) + paths = default_cert_files; + else + paths.push_back(conf_path); + for (const auto& path: paths) + { + try + { + Botan::DataSource_Stream bundle(path); + log_debug("Using ca bundle: ", path); + while (!bundle.end_of_data() && bundle.check_available(27)) + { + // TODO: remove this work-around for Botan 1.11.29 + // https://github.com/randombit/botan/issues/438#issuecomment-192866796 + // Note that every certificate that fails to be transcoded into latin-1 + // will be ignored. As a result, some TLS connection may be refused + // because the certificate is signed by an issuer that was ignored. + try { + const Botan::X509_Certificate cert(bundle); + BasicCredentialsManager::certificate_store.add_certificate(cert); + } catch (const Botan::Decoding_Error& error) + { + continue; + } + } + // Only use the first file that can successfully be read. + goto success; + } + catch (Botan::Stream_IO_Error& e) + { + log_debug(e.what()); + } + } + // If we could not open one of the files, print a warning + log_warning("The CA could not be loaded, TLS negociation will probably fail."); + success: + BasicCredentialsManager::certs_loaded = true; +} + +std::vector<Botan::Certificate_Store*> BasicCredentialsManager::trusted_certificate_authorities(const std::string&, const std::string&) +{ + return {&this->certificate_store}; +} + +#endif diff --git a/louloulibs/network/credentials_manager.hpp b/louloulibs/network/credentials_manager.hpp new file mode 100644 index 0000000..0fc4b89 --- /dev/null +++ b/louloulibs/network/credentials_manager.hpp @@ -0,0 +1,39 @@ +#pragma once + +#include "louloulibs.h" + +#ifdef BOTAN_FOUND + +#include <botan/botan.h> +#include <botan/tls_client.h> + +class TCPSocketHandler; + +class BasicCredentialsManager: public Botan::Credentials_Manager +{ +public: + BasicCredentialsManager(const TCPSocketHandler* const socket_handler); + + BasicCredentialsManager(BasicCredentialsManager&&) = delete; + BasicCredentialsManager(const BasicCredentialsManager&) = delete; + BasicCredentialsManager& operator=(const BasicCredentialsManager&) = delete; + BasicCredentialsManager& operator=(BasicCredentialsManager&&) = delete; + + void verify_certificate_chain(const std::string& type, + const std::string& purported_hostname, + const std::vector<Botan::X509_Certificate>&) override final; + std::vector<Botan::Certificate_Store*> trusted_certificate_authorities(const std::string& type, + const std::string& context) override final; + void set_trusted_fingerprint(const std::string& fingerprint); + +private: + const TCPSocketHandler* const socket_handler; + + static void load_certs(); + static Botan::Certificate_Store_In_Memory certificate_store; + static bool certs_loaded; + std::string trusted_fingerprint; +}; + +#endif //BOTAN_FOUND + diff --git a/louloulibs/network/dns_handler.cpp b/louloulibs/network/dns_handler.cpp new file mode 100644 index 0000000..e267944 --- /dev/null +++ b/louloulibs/network/dns_handler.cpp @@ -0,0 +1,134 @@ +#include <louloulibs.h> +#ifdef CARES_FOUND + +#include <network/dns_socket_handler.hpp> +#include <network/dns_handler.hpp> +#include <network/poller.hpp> + +#include <utils/timed_events.hpp> + +#include <algorithm> +#include <stdexcept> + +DNSHandler DNSHandler::instance; + +using namespace std::string_literals; +DNSHandler::DNSHandler(): + socket_handlers{}, + channel{nullptr} +{ + int ares_error; + if ((ares_error = ::ares_library_init(ARES_LIB_INIT_ALL)) != 0) + throw std::runtime_error("Failed to initialize c-ares lib: "s + ares_strerror(ares_error)); + struct ares_options options = {}; + // The default timeout values are way too high + options.timeout = 1000; + options.tries = 3; + if ((ares_error = ::ares_init_options(&this->channel, + &options, + ARES_OPT_TIMEOUTMS|ARES_OPT_TRIES)) != ARES_SUCCESS) + throw std::runtime_error("Failed to initialize c-ares channel: "s + ares_strerror(ares_error)); +} + +ares_channel& DNSHandler::get_channel() +{ + return this->channel; +} + +void DNSHandler::destroy() +{ + this->remove_all_sockets_from_poller(); + this->socket_handlers.clear(); + ::ares_destroy(this->channel); + ::ares_library_cleanup(); +} + +void DNSHandler::gethostbyname(const std::string& name, ares_host_callback callback, + void* data, int family) +{ + if (family == AF_INET) + ::ares_gethostbyname(this->channel, name.data(), family, + callback, data); + else + ::ares_gethostbyname(this->channel, name.data(), family, + callback, data); +} + +void DNSHandler::watch_dns_sockets(std::shared_ptr<Poller>& poller) +{ + fd_set readers; + fd_set writers; + + FD_ZERO(&readers); + FD_ZERO(&writers); + + int ndfs = ::ares_fds(this->channel, &readers, &writers); + // For each existing DNS socket, see if we are still supposed to watch it, + // if not then erase it + this->socket_handlers.erase( + std::remove_if(this->socket_handlers.begin(), this->socket_handlers.end(), + [&readers](const auto& dns_socket) + { + return !FD_ISSET(dns_socket->get_socket(), &readers); + }), + this->socket_handlers.end()); + + for (auto i = 0; i < ndfs; ++i) + { + bool read = FD_ISSET(i, &readers); + bool write = FD_ISSET(i, &writers); + // Look for the DNSSocketHandler with this fd + auto it = std::find_if(this->socket_handlers.begin(), + this->socket_handlers.end(), + [i](const auto& socket_handler) + { + return i == socket_handler->get_socket(); + }); + if (!read && !write) // No need to read or write to it + { // If found, erase it and stop watching it because it is not + // needed anymore + if (it != this->socket_handlers.end()) + // The socket destructor removes it from the poller + this->socket_handlers.erase(it); + } + else // We need to write and/or read to it + { // If not found, create it because we need to watch it + if (it == this->socket_handlers.end()) + { + this->socket_handlers.emplace(this->socket_handlers.begin(), + std::make_unique<DNSSocketHandler>(poller, *this, i)); + it = this->socket_handlers.begin(); + } + poller->add_socket_handler(it->get()); + if (write) + poller->watch_send_events(it->get()); + } + } + // Cancel previous timer, if any. + TimedEventsManager::instance().cancel("DNS timeout"); + struct timeval tv; + struct timeval* tvp; + tvp = ::ares_timeout(this->channel, NULL, &tv); + if (tvp) + { + auto future_time = std::chrono::steady_clock::now() + std::chrono::seconds(tvp->tv_sec) + \ + std::chrono::microseconds(tvp->tv_usec); + TimedEventsManager::instance().add_event(TimedEvent(std::move(future_time), + [this]() + { + for (auto& dns_socket_handler: this->socket_handlers) + dns_socket_handler->on_recv(); + }, + "DNS timeout")); + } +} + +void DNSHandler::remove_all_sockets_from_poller() +{ + for (const auto& socket_handler: this->socket_handlers) + { + socket_handler->remove_from_poller(); + } +} + +#endif /* CARES_FOUND */ diff --git a/louloulibs/network/dns_handler.hpp b/louloulibs/network/dns_handler.hpp new file mode 100644 index 0000000..fd1729d --- /dev/null +++ b/louloulibs/network/dns_handler.hpp @@ -0,0 +1,58 @@ +#pragma once + +#include <louloulibs.h> +#ifdef CARES_FOUND + +class TCPSocketHandler; +class Poller; +class DNSSocketHandler; + +# include <ares.h> +# include <memory> +# include <string> +# include <vector> + +/** + * Class managing DNS resolution. It should only be statically instanciated + * once in SocketHandler. It manages ares channel and calls various + * functions of that library. + */ + +class DNSHandler +{ +public: + DNSHandler(); + ~DNSHandler() = default; + DNSHandler(const DNSHandler&) = delete; + DNSHandler(DNSHandler&&) = delete; + DNSHandler& operator=(const DNSHandler&) = delete; + DNSHandler& operator=(DNSHandler&&) = delete; + + void gethostbyname(const std::string& name, ares_host_callback callback, + void* socket_handler, int family); + /** + * Call ares_fds to know what fd needs to be watched by the poller, create + * or destroy DNSSocketHandlers depending on the result. + */ + void watch_dns_sockets(std::shared_ptr<Poller>& poller); + /** + * Destroy and stop watching all the DNS sockets. Then de-init the channel + * and library. + */ + void destroy(); + void remove_all_sockets_from_poller(); + ares_channel& get_channel(); + + static DNSHandler instance; + +private: + /** + * The list of sockets that needs to be watched, according to the last + * call to ares_fds. DNSSocketHandlers are added to it or removed from it + * in the watch_dns_sockets() method + */ + std::vector<std::unique_ptr<DNSSocketHandler>> socket_handlers; + ares_channel channel; +}; + +#endif /* CARES_FOUND */ diff --git a/louloulibs/network/dns_socket_handler.cpp b/louloulibs/network/dns_socket_handler.cpp new file mode 100644 index 0000000..5fd08cb --- /dev/null +++ b/louloulibs/network/dns_socket_handler.cpp @@ -0,0 +1,48 @@ +#include <louloulibs.h> +#ifdef CARES_FOUND + +#include <network/dns_socket_handler.hpp> +#include <network/dns_handler.hpp> +#include <network/poller.hpp> + +#include <ares.h> + +DNSSocketHandler::DNSSocketHandler(std::shared_ptr<Poller> poller, + DNSHandler& handler, + const socket_t socket): + SocketHandler(poller, socket), + handler(handler) +{ +} + +void DNSSocketHandler::connect() +{ +} + +void DNSSocketHandler::on_recv() +{ + // always stop watching send and read events. We will re-watch them if the + // next call to ares_fds tell us to + this->handler.remove_all_sockets_from_poller(); + ::ares_process_fd(DNSHandler::instance.get_channel(), this->socket, ARES_SOCKET_BAD); +} + +void DNSSocketHandler::on_send() +{ + // always stop watching send and read events. We will re-watch them if the + // next call to ares_fds tell us to + this->handler.remove_all_sockets_from_poller(); + ::ares_process_fd(DNSHandler::instance.get_channel(), ARES_SOCKET_BAD, this->socket); +} + +bool DNSSocketHandler::is_connected() const +{ + return true; +} + +void DNSSocketHandler::remove_from_poller() +{ + this->poller->remove_socket_handler(this->socket); +} + +#endif /* CARES_FOUND */ diff --git a/louloulibs/network/dns_socket_handler.hpp b/louloulibs/network/dns_socket_handler.hpp new file mode 100644 index 0000000..0570196 --- /dev/null +++ b/louloulibs/network/dns_socket_handler.hpp @@ -0,0 +1,49 @@ +#pragma once + +#include <louloulibs.h> +#ifdef CARES_FOUND + +#include <network/socket_handler.hpp> +#include <ares.h> + +/** + * Manage a socket returned by ares_fds. We do not create, open or close the + * socket ourself: this is done by c-ares. We just call ares_process_fd() + * with the correct parameters, depending on what can be done on that socket + * (Poller reported it to be writable or readeable) + */ + +class DNSHandler; + +class DNSSocketHandler: public SocketHandler +{ +public: + explicit DNSSocketHandler(std::shared_ptr<Poller> poller, DNSHandler& handler, const socket_t socket); + ~DNSSocketHandler() = default; + DNSSocketHandler(const DNSSocketHandler&) = delete; + DNSSocketHandler(DNSSocketHandler&&) = delete; + DNSSocketHandler& operator=(const DNSSocketHandler&) = delete; + DNSSocketHandler& operator=(DNSSocketHandler&&) = delete; + + /** + * Just call dns_process_fd, c-ares will do its work of send()ing or + * recv()ing the data it wants on that socket. + */ + void on_recv() override final; + void on_send() override final; + /** + * Do nothing, because we are always considered to be connected, since the + * connection is done by c-ares and not by us. + */ + void connect() override final; + /** + * Always true, see the comment for connect() + */ + bool is_connected() const override final; + void remove_from_poller(); + +private: + DNSHandler& handler; +}; + +#endif // CARES_FOUND diff --git a/louloulibs/network/poller.cpp b/louloulibs/network/poller.cpp new file mode 100644 index 0000000..8a6fd97 --- /dev/null +++ b/louloulibs/network/poller.cpp @@ -0,0 +1,228 @@ +#include <network/poller.hpp> +#include <logger/logger.hpp> +#include <utils/timed_events.hpp> + +#include <assert.h> +#include <errno.h> +#include <stdio.h> +#include <signal.h> +#include <unistd.h> + +#include <cstring> +#include <iostream> +#include <stdexcept> + +Poller::Poller() +{ +#if POLLER == POLL + this->nfds = 0; +#elif POLLER == EPOLL + this->epfd = ::epoll_create1(0); + if (this->epfd == -1) + { + log_error("epoll failed: ", strerror(errno)); + throw std::runtime_error("Could not create epoll instance"); + } +#endif +} + +Poller::~Poller() +{ +#if POLLER == EPOLL + if (this->epfd > 0) + ::close(this->epfd); +#endif +} + +void Poller::add_socket_handler(SocketHandler* socket_handler) +{ + // Don't do anything if the socket is already managed + const auto it = this->socket_handlers.find(socket_handler->get_socket()); + if (it != this->socket_handlers.end()) + return ; + + this->socket_handlers.emplace(socket_handler->get_socket(), socket_handler); + + // We always watch all sockets for receive events +#if POLLER == POLL + this->fds[this->nfds].fd = socket_handler->get_socket(); + this->fds[this->nfds].events = POLLIN; + this->nfds++; +#endif +#if POLLER == EPOLL + struct epoll_event event = {EPOLLIN, {socket_handler}}; + const int res = ::epoll_ctl(this->epfd, EPOLL_CTL_ADD, socket_handler->get_socket(), &event); + if (res == -1) + { + log_error("epoll_ctl failed: ", strerror(errno)); + throw std::runtime_error("Could not add socket to epoll"); + } +#endif +} + +void Poller::remove_socket_handler(const socket_t socket) +{ + const auto it = this->socket_handlers.find(socket); + if (it == this->socket_handlers.end()) + throw std::runtime_error("Trying to remove a SocketHandler that is not managed"); + this->socket_handlers.erase(it); + +#if POLLER == POLL + for (size_t i = 0; i < this->nfds; i++) + { + if (this->fds[i].fd == socket) + { + // Move all subsequent pollfd by one on the left, erasing the + // value of the one we remove + for (size_t j = i; j < this->nfds - 1; ++j) + { + this->fds[j].fd = this->fds[j+1].fd; + this->fds[j].events= this->fds[j+1].events; + } + this->nfds--; + } + } +#elif POLLER == EPOLL + const int res = ::epoll_ctl(this->epfd, EPOLL_CTL_DEL, socket, nullptr); + if (res == -1) + { + log_error("epoll_ctl failed: ", strerror(errno)); + throw std::runtime_error("Could not remove socket from epoll"); + } +#endif +} + +void Poller::watch_send_events(SocketHandler* socket_handler) +{ +#if POLLER == POLL + for (size_t i = 0; i <= this->nfds; ++i) + { + if (this->fds[i].fd == socket_handler->get_socket()) + { + this->fds[i].events = POLLIN|POLLOUT; + return; + } + } + throw std::runtime_error("Cannot watch a non-registered socket for send events"); +#elif POLLER == EPOLL + struct epoll_event event = {EPOLLIN|EPOLLOUT, {socket_handler}}; + const int res = ::epoll_ctl(this->epfd, EPOLL_CTL_MOD, socket_handler->get_socket(), &event); + if (res == -1) + { + log_error("epoll_ctl failed: ", strerror(errno)); + throw std::runtime_error("Could not modify socket flags in epoll"); + } +#endif +} + +void Poller::stop_watching_send_events(SocketHandler* socket_handler) +{ +#if POLLER == POLL + for (size_t i = 0; i <= this->nfds; ++i) + { + if (this->fds[i].fd == socket_handler->get_socket()) + { + this->fds[i].events = POLLIN; + return; + } + } + throw std::runtime_error("Cannot watch a non-registered socket for send events"); +#elif POLLER == EPOLL + struct epoll_event event = {EPOLLIN, {socket_handler}}; + const int res = ::epoll_ctl(this->epfd, EPOLL_CTL_MOD, socket_handler->get_socket(), &event); + if (res == -1) + { + log_error("epoll_ctl failed: ", strerror(errno)); + throw std::runtime_error("Could not modify socket flags in epoll"); + } +#endif +} + +int Poller::poll(const std::chrono::milliseconds& timeout) +{ + if (this->socket_handlers.empty() && timeout == utils::no_timeout) + return -1; +#if POLLER == POLL + // Convert our nice timeout into this ugly struct + struct timespec timeout_ts; + struct timespec* timeout_tsp; + if (timeout > 0s) + { + auto seconds = std::chrono::duration_cast<std::chrono::seconds>(timeout); + timeout_ts.tv_sec = seconds.count(); + timeout_ts.tv_nsec = std::chrono::duration_cast<std::chrono::nanoseconds>(timeout - seconds).count(); + timeout_tsp = &timeout_ts; + } + else + timeout_tsp = nullptr; + + // Unblock all signals, only during the ppoll call + sigset_t empty_signal_set; + sigemptyset(&empty_signal_set); + int nb_events = ::ppoll(this->fds, this->nfds, timeout_tsp, + &empty_signal_set); + if (nb_events < 0) + { + if (errno == EINTR) + return true; + log_error("poll failed: ", strerror(errno)); + throw std::runtime_error("Poll failed"); + } + // We cannot possibly have more ready events than the number of fds we are + // watching + assert(static_cast<unsigned int>(nb_events) <= this->nfds); + for (size_t i = 0; i <= this->nfds && nb_events != 0; ++i) + { + auto socket_handler = this->socket_handlers.at(this->fds[i].fd); + if (this->fds[i].revents == 0) + continue; + else if (this->fds[i].revents & POLLIN && socket_handler->is_connected()) + { + socket_handler->on_recv(); + nb_events--; + } + else if (this->fds[i].revents & POLLOUT && socket_handler->is_connected()) + { + socket_handler->on_send(); + nb_events--; + } + else if (this->fds[i].revents & POLLOUT) + { + socket_handler->connect(); + nb_events--; + } + } + return 1; +#elif POLLER == EPOLL + static const size_t max_events = 12; + struct epoll_event revents[max_events]; + // Unblock all signals, only during the epoll_pwait call + sigset_t empty_signal_set; + sigemptyset(&empty_signal_set); + const int nb_events = ::epoll_pwait(this->epfd, revents, max_events, timeout.count(), + &empty_signal_set); + if (nb_events == -1) + { + if (errno == EINTR) + return 0; + log_error("epoll wait: ", strerror(errno)); + throw std::runtime_error("Epoll_wait failed"); + } + for (int i = 0; i < nb_events; ++i) + { + auto socket_handler = static_cast<SocketHandler*>(revents[i].data.ptr); + if (revents[i].events & EPOLLIN && socket_handler->is_connected()) + socket_handler->on_recv(); + else if (revents[i].events & EPOLLOUT && socket_handler->is_connected()) + socket_handler->on_send(); + else if (revents[i].events & EPOLLOUT) + socket_handler->connect(); + } + return nb_events; +#endif +} + +size_t Poller::size() const +{ + return this->socket_handlers.size(); +} diff --git a/louloulibs/network/poller.hpp b/louloulibs/network/poller.hpp new file mode 100644 index 0000000..fc1a1a1 --- /dev/null +++ b/louloulibs/network/poller.hpp @@ -0,0 +1,94 @@ +#pragma once + + +#include <network/socket_handler.hpp> + +#include <unordered_map> +#include <memory> +#include <chrono> + +#define POLL 1 +#define EPOLL 2 +#define KQUEUE 3 +#include <louloulibs.h> +#ifndef POLLER + #define POLLER POLL +#endif + +#if POLLER == POLL + #include <poll.h> + #define MAX_POLL_FD_NUMBER 4096 +#elif POLLER == EPOLL + #include <sys/epoll.h> +#else + #error Invalid POLLER value +#endif + +/** + * We pass some SocketHandlers to this Poller, which uses + * poll/epoll/kqueue/select etc to wait for events on these SocketHandlers, + * and call the callbacks when event occurs. + * + * TODO: support these pollers: + * - kqueue(2) + */ + +class Poller +{ +public: + explicit Poller(); + ~Poller(); + Poller(const Poller&) = delete; + Poller(Poller&&) = delete; + Poller& operator=(const Poller&) = delete; + Poller& operator=(Poller&&) = delete; + /** + * Add a SocketHandler to be monitored by this Poller. All receive events + * are always automatically watched. + */ + void add_socket_handler(SocketHandler* socket_handler); + /** + * Remove (and stop managing) a SocketHandler, designated by the given socket_t. + */ + void remove_socket_handler(const socket_t socket); + /** + * Signal the poller that he needs to watch for send events for the given + * SocketHandler. + */ + void watch_send_events(SocketHandler* socket_handler); + /** + * Signal the poller that he needs to stop watching for send events for + * this SocketHandler. + */ + void stop_watching_send_events(SocketHandler* socket_handler); + /** + * Wait for all watched events, and call the SocketHandlers' callbacks + * when one is ready. Returns if nothing happened before the provided + * timeout. If the timeout is 0, it waits forever. If there is no + * watched event, returns -1 immediately, ignoring the timeout value. + * Otherwise, returns the number of event handled. If 0 is returned this + * means that we were interrupted by a signal, or the timeout occured. + */ + int poll(const std::chrono::milliseconds& timeout); + /** + * Returns the number of SocketHandlers managed by the poller. + */ + size_t size() const; + +private: + /** + * A "list" of all the SocketHandlers that we manage, indexed by socket, + * because that's what is returned by select/poll/etc when an event + * occures. + */ + std::unordered_map<socket_t, SocketHandler*> socket_handlers; + +#if POLLER == POLL + struct pollfd fds[MAX_POLL_FD_NUMBER]; + nfds_t nfds; +#elif POLLER == EPOLL + int epfd; +#endif +}; + + diff --git a/louloulibs/network/resolver.cpp b/louloulibs/network/resolver.cpp new file mode 100644 index 0000000..9d6de23 --- /dev/null +++ b/louloulibs/network/resolver.cpp @@ -0,0 +1,214 @@ +#include <network/dns_handler.hpp> +#include <network/resolver.hpp> +#include <string.h> +#include <arpa/inet.h> + +using namespace std::string_literals; + +Resolver::Resolver(): +#ifdef CARES_FOUND + resolved4(false), + resolved6(false), + resolving(false), + cares_addrinfo(nullptr), + port{}, +#endif + resolved(false), + error_msg{} +{ +} + +void Resolver::resolve(const std::string& hostname, const std::string& port, + SuccessCallbackType success_cb, ErrorCallbackType error_cb) +{ + this->error_cb = error_cb; + this->success_cb = success_cb; +#ifdef CARES_FOUND + this->port = port; +#endif + + this->start_resolving(hostname, port); +} + +#ifdef CARES_FOUND +void Resolver::start_resolving(const std::string& hostname, const std::string&) +{ + this->resolving = true; + this->resolved = false; + this->resolved4 = false; + this->resolved6 = false; + + this->error_msg.clear(); + this->cares_addrinfo = nullptr; + + auto hostname4_resolved = [](void* arg, int status, int, + struct hostent* hostent) + { + Resolver* resolver = static_cast<Resolver*>(arg); + resolver->on_hostname4_resolved(status, hostent); + }; + auto hostname6_resolved = [](void* arg, int status, int, + struct hostent* hostent) + { + Resolver* resolver = static_cast<Resolver*>(arg); + resolver->on_hostname6_resolved(status, hostent); + }; + + DNSHandler::instance.gethostbyname(hostname, hostname6_resolved, + this, AF_INET6); + DNSHandler::instance.gethostbyname(hostname, hostname4_resolved, + this, AF_INET); +} + +void Resolver::on_hostname4_resolved(int status, struct hostent* hostent) +{ + this->resolved4 = true; + if (status == ARES_SUCCESS) + this->fill_ares_addrinfo4(hostent); + else + this->error_msg = ::ares_strerror(status); + + if (this->resolved4 && this->resolved6) + this->on_resolved(); +} + +void Resolver::on_hostname6_resolved(int status, struct hostent* hostent) +{ + this->resolved6 = true; + if (status == ARES_SUCCESS) + this->fill_ares_addrinfo6(hostent); + else + this->error_msg = ::ares_strerror(status); + + if (this->resolved4 && this->resolved6) + this->on_resolved(); +} + +void Resolver::on_resolved() +{ + this->resolved = true; + this->resolving = false; + if (!this->cares_addrinfo) + { + if (this->error_cb) + this->error_cb(this->error_msg.data()); + } + else + { + this->addr.reset(this->cares_addrinfo); + if (this->success_cb) + this->success_cb(this->addr.get()); + } +} + +void Resolver::fill_ares_addrinfo4(const struct hostent* hostent) +{ + struct addrinfo* prev = this->cares_addrinfo; + struct in_addr** address = reinterpret_cast<struct in_addr**>(hostent->h_addr_list); + + while (*address) + { + // Create a new addrinfo list element, and fill it + struct addrinfo* current = new struct addrinfo; + current->ai_flags = 0; + current->ai_family = hostent->h_addrtype; + current->ai_socktype = SOCK_STREAM; + current->ai_protocol = 0; + current->ai_addrlen = sizeof(struct sockaddr_in); + + struct sockaddr_in* addr = new struct sockaddr_in; + addr->sin_family = hostent->h_addrtype; + addr->sin_port = htons(strtoul(this->port.data(), nullptr, 10)); + addr->sin_addr.s_addr = (*address)->s_addr; + + current->ai_addr = reinterpret_cast<struct sockaddr*>(addr); + current->ai_next = nullptr; + current->ai_canonname = nullptr; + + current->ai_next = prev; + this->cares_addrinfo = current; + prev = current; + ++address; + } +} + +void Resolver::fill_ares_addrinfo6(const struct hostent* hostent) +{ + struct addrinfo* prev = this->cares_addrinfo; + struct in6_addr** address = reinterpret_cast<struct in6_addr**>(hostent->h_addr_list); + + while (*address) + { + // Create a new addrinfo list element, and fill it + struct addrinfo* current = new struct addrinfo; + current->ai_flags = 0; + current->ai_family = hostent->h_addrtype; + current->ai_socktype = SOCK_STREAM; + current->ai_protocol = 0; + current->ai_addrlen = sizeof(struct sockaddr_in6); + + struct sockaddr_in6* addr = new struct sockaddr_in6; + addr->sin6_family = hostent->h_addrtype; + addr->sin6_port = htons(strtoul(this->port.data(), nullptr, 10)); + ::memcpy(addr->sin6_addr.s6_addr, (*address)->s6_addr, 16); + addr->sin6_flowinfo = 0; + addr->sin6_scope_id = 0; + + current->ai_addr = reinterpret_cast<struct sockaddr*>(addr); + current->ai_canonname = nullptr; + + current->ai_next = prev; + this->cares_addrinfo = current; + prev = current; + ++address; + } +} + +#else // ifdef CARES_FOUND + +void Resolver::start_resolving(const std::string& hostname, const std::string& port) +{ + // If the resolution fails, the addr will be unset + this->addr.reset(nullptr); + + struct addrinfo hints; + memset(&hints, 0, sizeof(struct addrinfo)); + hints.ai_flags = 0; + hints.ai_family = AF_UNSPEC; + hints.ai_socktype = SOCK_STREAM; + hints.ai_protocol = 0; + + struct addrinfo* addr_res = nullptr; + const int res = ::getaddrinfo(hostname.data(), port.data(), + &hints, &addr_res); + + this->resolved = true; + + if (res != 0) + { + this->error_msg = gai_strerror(res); + if (this->error_cb) + this->error_cb(this->error_msg.data()); + } + else + { + this->addr.reset(addr_res); + if (this->success_cb) + this->success_cb(this->addr.get()); + } +} +#endif // ifdef CARES_FOUND + +std::string addr_to_string(const struct addrinfo* rp) +{ + char buf[INET6_ADDRSTRLEN]; + if (rp->ai_family == AF_INET) + return ::inet_ntop(rp->ai_family, + &reinterpret_cast<sockaddr_in*>(rp->ai_addr)->sin_addr, + buf, sizeof(buf)); + else if (rp->ai_family == AF_INET6) + return ::inet_ntop(rp->ai_family, + &reinterpret_cast<sockaddr_in6*>(rp->ai_addr)->sin6_addr, + buf, sizeof(buf)); + return {}; +} diff --git a/louloulibs/network/resolver.hpp b/louloulibs/network/resolver.hpp new file mode 100644 index 0000000..afe6e2b --- /dev/null +++ b/louloulibs/network/resolver.hpp @@ -0,0 +1,128 @@ +#pragma once + + +#include "louloulibs.h" + +#include <functional> +#include <memory> +#include <string> + +#include <sys/types.h> +#include <sys/socket.h> +#include <netdb.h> + +struct AddrinfoDeleter +{ + void operator()(struct addrinfo* addr) + { +#ifdef CARES_FOUND + while (addr) + { + delete addr->ai_addr; + auto next = addr->ai_next; + delete addr; + addr = next; + } +#else + freeaddrinfo(addr); +#endif + } +}; + +class Resolver +{ +public: + using ErrorCallbackType = std::function<void(const char*)>; + using SuccessCallbackType = std::function<void(const struct addrinfo*)>; + + Resolver(); + ~Resolver() = default; + Resolver(const Resolver&) = delete; + Resolver(Resolver&&) = delete; + Resolver& operator=(const Resolver&) = delete; + Resolver& operator=(Resolver&&) = delete; + + bool is_resolving() const + { +#ifdef CARES_FOUND + return this->resolving; +#else + return false; +#endif + } + + bool is_resolved() const + { + return this->resolved; + } + + const auto& get_result() const + { + return this->addr; + } + std::string get_error_message() const + { + return this->error_msg; + } + + void clear() + { +#ifdef CARES_FOUND + this->resolved6 = false; + this->resolved4 = false; + this->resolving = false; + this->cares_addrinfo = nullptr; + this->port.clear(); +#endif + this->resolved = false; + this->addr.reset(); + this->error_msg.clear(); + } + + void resolve(const std::string& hostname, const std::string& port, + SuccessCallbackType success_cb, ErrorCallbackType error_cb); + +private: + void start_resolving(const std::string& hostname, const std::string& port); +#ifdef CARES_FOUND + void on_hostname4_resolved(int status, struct hostent* hostent); + void on_hostname6_resolved(int status, struct hostent* hostent); + + void fill_ares_addrinfo4(const struct hostent* hostent); + void fill_ares_addrinfo6(const struct hostent* hostent); + + void on_resolved(); + + bool resolved4; + bool resolved6; + + bool resolving; + + /** + * When using c-ares to resolve the host asynchronously, we need the + * c-ares callbacks to fill a structure (a struct addrinfo, for + * compatibility with getaddrinfo and the rest of the code that works when + * c-ares is not used) with all returned values (for example an IPv6 and + * an IPv4). The pointer is given to the unique_ptr to manage its lifetime. + */ + struct addrinfo* cares_addrinfo; + std::string port; + +#endif + /** + * Tells if we finished the resolution process. It doesn't indicate if it + * was successful (it is true even if the result is an error). + */ + bool resolved; + std::string error_msg; + + + std::unique_ptr<struct addrinfo, AddrinfoDeleter> addr; + + ErrorCallbackType error_cb; + SuccessCallbackType success_cb; +}; + +std::string addr_to_string(const struct addrinfo* rp); + + diff --git a/louloulibs/network/socket_handler.hpp b/louloulibs/network/socket_handler.hpp new file mode 100644 index 0000000..eeb41fe --- /dev/null +++ b/louloulibs/network/socket_handler.hpp @@ -0,0 +1,42 @@ +#pragma once + +#include <louloulibs.h> +#include <memory> + +class Poller; + +using socket_t = int; + +class SocketHandler +{ +public: + explicit SocketHandler(std::shared_ptr<Poller> poller, const socket_t socket): + poller(poller), + socket(socket) + {} + virtual ~SocketHandler() {} + SocketHandler(const SocketHandler&) = delete; + SocketHandler(SocketHandler&&) = delete; + SocketHandler& operator=(const SocketHandler&) = delete; + SocketHandler& operator=(SocketHandler&&) = delete; + + virtual void on_recv() = 0; + virtual void on_send() = 0; + virtual void connect() = 0; + virtual bool is_connected() const = 0; + + socket_t get_socket() const + { return this->socket; } + +protected: + /** + * A pointer to the poller that manages us, because we need to communicate + * with it. + */ + std::shared_ptr<Poller> poller; + /** + * The handled socket. + */ + socket_t socket; +}; + diff --git a/louloulibs/network/tcp_socket_handler.cpp b/louloulibs/network/tcp_socket_handler.cpp new file mode 100644 index 0000000..5420b1c --- /dev/null +++ b/louloulibs/network/tcp_socket_handler.cpp @@ -0,0 +1,501 @@ +#include <network/tcp_socket_handler.hpp> +#include <network/dns_handler.hpp> + +#include <utils/timed_events.hpp> +#include <utils/scopeguard.hpp> +#include <network/poller.hpp> + +#include <logger/logger.hpp> +#include <sys/socket.h> +#include <sys/types.h> +#include <stdexcept> +#include <unistd.h> +#include <errno.h> +#include <cstring> +#include <fcntl.h> + +#ifdef BOTAN_FOUND +# include <botan/hex.h> +# include <botan/tls_exceptn.h> + +Botan::AutoSeeded_RNG TCPSocketHandler::rng; +Botan::TLS::Policy TCPSocketHandler::policy; +Botan::TLS::Session_Manager_In_Memory TCPSocketHandler::session_manager(TCPSocketHandler::rng); + +#endif + +#ifndef UIO_FASTIOV +# define UIO_FASTIOV 8 +#endif + +using namespace std::string_literals; +using namespace std::chrono_literals; + +namespace ph = std::placeholders; + +TCPSocketHandler::TCPSocketHandler(std::shared_ptr<Poller> poller): + SocketHandler(poller, -1), + use_tls(false), + connected(false), + connecting(false), + hostname_resolution_failed(false) +#ifdef BOTAN_FOUND + ,credential_manager(this) +#endif +{} + +TCPSocketHandler::~TCPSocketHandler() +{ + this->close(); +} + + +void TCPSocketHandler::init_socket(const struct addrinfo* rp) +{ + if (this->socket != -1) + ::close(this->socket); + if ((this->socket = ::socket(rp->ai_family, rp->ai_socktype, rp->ai_protocol)) == -1) + throw std::runtime_error("Could not create socket: "s + strerror(errno)); + // Bind the socket to a specific address, if specified + if (!this->bind_addr.empty()) + { + // Convert the address from string format to a sockaddr that can be + // used in bind() + struct addrinfo* result; + int err = ::getaddrinfo(this->bind_addr.data(), nullptr, nullptr, &result); + if (err != 0 || !result) + log_error("Failed to bind socket to ", this->bind_addr, ": ", + gai_strerror(err)); + else + { + utils::ScopeGuard sg([result](){ freeaddrinfo(result); }); + struct addrinfo* rp; + int bind_error = 0; + for (rp = result; rp; rp = rp->ai_next) + { + if ((bind_error = ::bind(this->socket, + reinterpret_cast<const struct sockaddr*>(rp->ai_addr), + rp->ai_addrlen)) == 0) + break; + } + if (!rp) + log_error("Failed to bind socket to ", this->bind_addr, ": ", + strerror(bind_error)); + else + log_info("Socket successfully bound to ", this->bind_addr); + } + } + int optval = 1; + if (::setsockopt(this->socket, SOL_SOCKET, SO_KEEPALIVE, &optval, sizeof(optval)) == -1) + log_warning("Failed to enable TCP keepalive on socket: ", strerror(errno)); + // Set the socket on non-blocking mode. This is useful to receive a EAGAIN + // error when connect() would block, to not block the whole process if a + // remote is not responsive. + const int existing_flags = ::fcntl(this->socket, F_GETFL, 0); + if ((existing_flags == -1) || + (::fcntl(this->socket, F_SETFL, existing_flags | O_NONBLOCK) == -1)) + throw std::runtime_error("Could not initialize socket: "s + strerror(errno)); +} + +void TCPSocketHandler::connect(const std::string& address, const std::string& port, const bool tls) +{ + this->address = address; + this->port = port; + this->use_tls = tls; + + utils::ScopeGuard sg; + + struct addrinfo* addr_res; + + if (!this->connecting) + { + // Get the addrinfo from getaddrinfo (or ares_gethostbyname), only if + // this is the first call of this function. + if (!this->resolver.is_resolved()) + { + log_info("Trying to connect to ", address, ":", port); + // Start the asynchronous process of resolving the hostname. Once + // the addresses have been found and `resolved` has been set to true + // (but connecting will still be false), TCPSocketHandler::connect() + // needs to be called, again. + this->resolver.resolve(address, port, + [this](const struct addrinfo*) + { + log_debug("Resolution success, calling connect() again"); + this->connect(); + }, + [this](const char*) + { + log_debug("Resolution failed, calling connect() again"); + this->connect(); + }); + return; + } + else + { + // The c-ares resolved the hostname and the available addresses + // where saved in the cares_addrinfo linked list. Now, just use + // this list to try to connect. + addr_res = this->resolver.get_result().get(); + if (!addr_res) + { + this->hostname_resolution_failed = true; + const auto msg = this->resolver.get_error_message(); + this->close(); + this->on_connection_failed(msg); + return ; + } + } + } + else + { // This function is called again, use the saved addrinfo structure, + // instead of re-doing the whole getaddrinfo process. + addr_res = &this->addrinfo; + } + + for (struct addrinfo* rp = addr_res; rp; rp = rp->ai_next) + { + if (!this->connecting) + { + try { + this->init_socket(rp); + } + catch (const std::runtime_error& error) { + log_error("Failed to init socket: ", error.what()); + break; + } + } + + this->display_resolved_ip(rp); + + if (::connect(this->socket, rp->ai_addr, rp->ai_addrlen) == 0 + || errno == EISCONN) + { + log_info("Connection success."); + TimedEventsManager::instance().cancel("connection_timeout"s + + std::to_string(this->socket)); + this->poller->add_socket_handler(this); + this->connected = true; + this->connecting = false; +#ifdef BOTAN_FOUND + if (this->use_tls) + this->start_tls(); +#endif + this->on_connected(); + return ; + } + else if (errno == EINPROGRESS || errno == EALREADY) + { // retry this process later, when the socket + // is ready to be written on. + this->connecting = true; + this->poller->add_socket_handler(this); + this->poller->watch_send_events(this); + // Save the addrinfo structure, to use it on the next call + this->ai_addrlen = rp->ai_addrlen; + memcpy(&this->ai_addr, rp->ai_addr, this->ai_addrlen); + memcpy(&this->addrinfo, rp, sizeof(struct addrinfo)); + this->addrinfo.ai_addr = reinterpret_cast<struct sockaddr*>(&this->ai_addr); + this->addrinfo.ai_next = nullptr; + // If the connection has not succeeded or failed in 5s, we consider + // it to have failed + TimedEventsManager::instance().add_event( + TimedEvent(std::chrono::steady_clock::now() + 5s, + std::bind(&TCPSocketHandler::on_connection_timeout, this), + "connection_timeout"s + std::to_string(this->socket))); + return ; + } + log_info("Connection failed:", strerror(errno)); + } + log_error("All connection attempts failed."); + this->close(); + this->on_connection_failed(strerror(errno)); + return ; +} + +void TCPSocketHandler::on_connection_timeout() +{ + this->close(); + this->on_connection_failed("connection timed out"); +} + +void TCPSocketHandler::connect() +{ + this->connect(this->address, this->port, this->use_tls); +} + +void TCPSocketHandler::on_recv() +{ +#ifdef BOTAN_FOUND + if (this->use_tls) + this->tls_recv(); + else +#endif + this->plain_recv(); +} + +void TCPSocketHandler::plain_recv() +{ + static constexpr size_t buf_size = 4096; + char buf[buf_size]; + void* recv_buf = this->get_receive_buffer(buf_size); + + if (recv_buf == nullptr) + recv_buf = buf; + + const ssize_t size = this->do_recv(recv_buf, buf_size); + + if (size > 0) + { + if (buf == recv_buf) + { + // data needs to be placed in the in_buf string, because no buffer + // was provided to receive that data directly. The in_buf buffer + // will be handled in parse_in_buffer() + this->in_buf += std::string(buf, size); + } + this->parse_in_buffer(size); + } +} + +ssize_t TCPSocketHandler::do_recv(void* recv_buf, const size_t buf_size) +{ + ssize_t size = ::recv(this->socket, recv_buf, buf_size, 0); + if (0 == size) + { + this->on_connection_close(""); + this->close(); + } + else if (-1 == size) + { + if (this->connecting) + log_warning("Error connecting: ", strerror(errno)); + else + log_warning("Error while reading from socket: ", strerror(errno)); + // Remember if we were connecting, or already connected when this + // happened, because close() sets this->connecting to false + const auto were_connecting = this->connecting; + this->close(); + if (were_connecting) + this->on_connection_failed(strerror(errno)); + else + this->on_connection_close(strerror(errno)); + } + return size; +} + +void TCPSocketHandler::on_send() +{ + struct iovec msg_iov[UIO_FASTIOV] = {}; + struct msghdr msg{nullptr, 0, + msg_iov, + 0, nullptr, 0, 0}; + for (const std::string& s: this->out_buf) + { + // unconsting the content of s is ok, sendmsg will never modify it + msg_iov[msg.msg_iovlen].iov_base = const_cast<char*>(s.data()); + msg_iov[msg.msg_iovlen].iov_len = s.size(); + if (++msg.msg_iovlen == UIO_FASTIOV) + break; + } + ssize_t res = ::sendmsg(this->socket, &msg, MSG_NOSIGNAL); + if (res < 0) + { + log_error("sendmsg failed: ", strerror(errno)); + this->on_connection_close(strerror(errno)); + this->close(); + } + else + { + // remove all the strings that were successfully sent. + for (auto it = this->out_buf.begin(); + it != this->out_buf.end();) + { + if (static_cast<size_t>(res) >= (*it).size()) + { + res -= (*it).size(); + it = this->out_buf.erase(it); + } + else + { + // If one string has partially been sent, we use substr to + // crop it + if (res > 0) + (*it) = (*it).substr(res, std::string::npos); + break; + } + } + if (this->out_buf.empty()) + this->poller->stop_watching_send_events(this); + } +} + +void TCPSocketHandler::close() +{ + TimedEventsManager::instance().cancel("connection_timeout"s + + std::to_string(this->socket)); + if (this->connected || this->connecting) + this->poller->remove_socket_handler(this->get_socket()); + if (this->socket != -1) + { + ::close(this->socket); + this->socket = -1; + } + this->connected = false; + this->connecting = false; + this->in_buf.clear(); + this->out_buf.clear(); + this->port.clear(); + this->resolver.clear(); +} + +void TCPSocketHandler::display_resolved_ip(struct addrinfo* rp) const +{ + if (rp->ai_family == AF_INET) + log_debug("Trying IPv4 address ", addr_to_string(rp)); + else if (rp->ai_family == AF_INET6) + log_debug("Trying IPv6 address ", addr_to_string(rp)); +} + +void TCPSocketHandler::send_data(std::string&& data) +{ +#ifdef BOTAN_FOUND + if (this->use_tls) + try { + this->tls_send(std::move(data)); + } catch (const Botan::TLS::TLS_Exception& e) { + this->on_connection_close("TLS error: "s + e.what()); + this->close(); + return ; + } + else +#endif + this->raw_send(std::move(data)); +} + +void TCPSocketHandler::raw_send(std::string&& data) +{ + if (data.empty()) + return ; + this->out_buf.emplace_back(std::move(data)); + if (this->connected) + this->poller->watch_send_events(this); +} + +void TCPSocketHandler::send_pending_data() +{ + if (this->connected && !this->out_buf.empty()) + this->poller->watch_send_events(this); +} + +bool TCPSocketHandler::is_connected() const +{ + return this->connected; +} + +bool TCPSocketHandler::is_connecting() const +{ + return this->connecting || this->resolver.is_resolving(); +} + +void* TCPSocketHandler::get_receive_buffer(const size_t) const +{ + return nullptr; +} + +#ifdef BOTAN_FOUND +void TCPSocketHandler::start_tls() +{ + Botan::TLS::Server_Information server_info(this->address, "irc", std::stoul(this->port)); + this->tls = std::make_unique<Botan::TLS::Client>( + std::bind(&TCPSocketHandler::tls_output_fn, this, ph::_1, ph::_2), + std::bind(&TCPSocketHandler::tls_data_cb, this, ph::_1, ph::_2), + std::bind(&TCPSocketHandler::tls_alert_cb, this, ph::_1, ph::_2, ph::_3), + std::bind(&TCPSocketHandler::tls_handshake_cb, this, ph::_1), + session_manager, this->credential_manager, policy, + rng, server_info, Botan::TLS::Protocol_Version::latest_tls_version()); +} + +void TCPSocketHandler::tls_recv() +{ + static constexpr size_t buf_size = 4096; + char recv_buf[buf_size]; + + const ssize_t size = this->do_recv(recv_buf, buf_size); + if (size > 0) + { + const bool was_active = this->tls->is_active(); + try { + this->tls->received_data(reinterpret_cast<const Botan::byte*>(recv_buf), + static_cast<size_t>(size)); + } catch (const Botan::TLS::TLS_Exception& e) { + // May happen if the server sends malformed TLS data (buggy server, + // or more probably we are just connected to a server that sends + // plain-text) + this->on_connection_close("TLS error: "s + e.what()); + this->close(); + return ; + } + if (!was_active && this->tls->is_active()) + this->on_tls_activated(); + } +} + +void TCPSocketHandler::tls_send(std::string&& data) +{ + // We may not be connected yet, or the tls session has + // not yet been negociated + if (this->tls && this->tls->is_active()) + { + const bool was_active = this->tls->is_active(); + if (!this->pre_buf.empty()) + { + this->tls->send(reinterpret_cast<const Botan::byte*>(this->pre_buf.data()), + this->pre_buf.size()); + this->pre_buf = ""; + } + if (!data.empty()) + this->tls->send(reinterpret_cast<const Botan::byte*>(data.data()), + data.size()); + if (!was_active && this->tls->is_active()) + this->on_tls_activated(); + } + else + this->pre_buf += data; +} + +void TCPSocketHandler::tls_data_cb(const Botan::byte* data, size_t size) +{ + this->in_buf += std::string(reinterpret_cast<const char*>(data), + size); + if (!this->in_buf.empty()) + this->parse_in_buffer(size); +} + +void TCPSocketHandler::tls_output_fn(const Botan::byte* data, size_t size) +{ + this->raw_send(std::string(reinterpret_cast<const char*>(data), size)); +} + +void TCPSocketHandler::tls_alert_cb(Botan::TLS::Alert alert, const Botan::byte*, size_t) +{ + log_debug("tls_alert: ", alert.type_string()); +} + +bool TCPSocketHandler::tls_handshake_cb(const Botan::TLS::Session& session) +{ + log_debug("Handshake with ", session.server_info().hostname(), " complete.", + " Version: ", session.version().to_string(), + " using ", session.ciphersuite().to_string()); + if (!session.session_id().empty()) + log_debug("Session ID ", Botan::hex_encode(session.session_id())); + if (!session.session_ticket().empty()) + log_debug("Session ticket ", Botan::hex_encode(session.session_ticket())); + return true; +} + +void TCPSocketHandler::on_tls_activated() +{ + this->send_data({}); +} + +#endif // BOTAN_FOUND diff --git a/louloulibs/network/tcp_socket_handler.hpp b/louloulibs/network/tcp_socket_handler.hpp new file mode 100644 index 0000000..b0ba493 --- /dev/null +++ b/louloulibs/network/tcp_socket_handler.hpp @@ -0,0 +1,274 @@ +#pragma once + + +#include "louloulibs.h" + +#include <network/socket_handler.hpp> +#include <network/resolver.hpp> + +#include <network/credentials_manager.hpp> + +#include <sys/types.h> +#include <sys/socket.h> +#include <netinet/in.h> +#include <netdb.h> + +#include <vector> +#include <memory> +#include <string> +#include <list> + +/** + * An interface, with a series of callbacks that should be implemented in + * subclasses that deal with a socket. These callbacks are called on various events + * (read/write/timeout, etc) when they are notified to a poller + * (select/poll/epoll etc) + */ +class TCPSocketHandler: public SocketHandler +{ +protected: + ~TCPSocketHandler(); +public: + explicit TCPSocketHandler(std::shared_ptr<Poller> poller); + TCPSocketHandler(const TCPSocketHandler&) = delete; + TCPSocketHandler(TCPSocketHandler&&) = delete; + TCPSocketHandler& operator=(const TCPSocketHandler&) = delete; + TCPSocketHandler& operator=(TCPSocketHandler&&) = delete; + + /** + * Connect to the remote server, and call on_connected() if this + * succeeds. If tls is true, we set use_tls to true and will also call + * start_tls() when the connection succeeds. + */ + void connect(const std::string& address, const std::string& port, const bool tls); + void connect() override final; + /** + * Reads raw data from the socket. And pass it to parse_in_buffer() + * If we are using TLS on this connection, we call tls_recv() + */ + void on_recv() override final; + /** + * Write as much data from out_buf as possible, in the socket. + */ + void on_send() override final; + /** + * Add the given data to out_buf and tell our poller that we want to be + * notified when a send event is ready. + * + * This can be overriden if we want to modify the data before sending + * it. For example if we want to encrypt it. + */ + void send_data(std::string&& data); + /** + * Watch the socket for send events, if our out buffer is not empty. + */ + void send_pending_data(); + /** + * Close the connection, remove us from the poller + */ + void close(); + /** + * Called by a TimedEvent, when the connection did not succeed or fail + * after a given time. + */ + void on_connection_timeout(); + /** + * Called when the connection is successful. + */ + virtual void on_connected() = 0; + /** + * Called when the connection fails. Not when it is closed later, just at + * the connect() call. + */ + virtual void on_connection_failed(const std::string& reason) = 0; + /** + * Called when we detect a disconnection from the remote host. + */ + virtual void on_connection_close(const std::string& error) = 0; + /** + * Handle/consume (some of) the data received so far. The data to handle + * may be in the in_buf buffer, or somewhere else, depending on what + * get_receive_buffer() returned. If some data is used from in_buf, it + * should be truncated, only the unused data should be left untouched. + * + * The size argument is the size of the last chunk of data that was added to the buffer. + */ + virtual void parse_in_buffer(const size_t size) = 0; +#ifdef BOTAN_FOUND + /** + * Tell whether the credential manager should cancel the connection when the + * certificate is invalid. + */ + virtual bool abort_on_invalid_cert() const + { + return true; + } +#endif + bool is_connected() const override final; + bool is_connecting() const; + +private: + /** + * Initialize the socket with the parameters contained in the given + * addrinfo structure. + */ + void init_socket(const struct addrinfo* rp); + /** + * Reads from the socket into the provided buffer. If an error occurs + * (read returns <= 0), the handling of the error is done here (close the + * connection, log a message, etc). + * + * Returns the value returned by ::recv(), so the buffer should not be + * used if it’s not positive. + */ + ssize_t do_recv(void* recv_buf, const size_t buf_size); + /** + * Reads data from the socket and calls parse_in_buffer with it. + */ + void plain_recv(); + /** + * Mark the given data as ready to be sent, as-is, on the socket, as soon + * as we can. + */ + void raw_send(std::string&& data); + +#ifdef BOTAN_FOUND + /** + * Create the TLS::Client object, with all the callbacks etc. This must be + * called only when we know we are able to send TLS-encrypted data over + * the socket. + */ + void start_tls(); + /** + * An additional step to pass the data into our tls object to decrypt it + * before passing it to parse_in_buffer. + */ + void tls_recv(); + /** + * Pass the data to the tls object in order to encrypt it. The tls object + * will then call raw_send as a callback whenever data as been encrypted + * and can be sent on the socket. + */ + void tls_send(std::string&& data); + /** + * Called by the tls object that some data has been decrypt. We call + * parse_in_buffer() to handle that unencrypted data. + */ + void tls_data_cb(const Botan::byte* data, size_t size); + /** + * Called by the tls object to indicate that some data has been encrypted + * and is now ready to be sent on the socket as is. + */ + void tls_output_fn(const Botan::byte* data, size_t size); + /** + * Called by the tls object to indicate that a TLS alert has been + * received. We don’t use it, we just log some message, at the moment. + */ + void tls_alert_cb(Botan::TLS::Alert alert, const Botan::byte*, size_t); + /** + * Called by the tls object at the end of the TLS handshake. We don't do + * anything here appart from logging the TLS session information. + */ + bool tls_handshake_cb(const Botan::TLS::Session& session); + /** + * Called whenever the tls session goes from inactive to active. This + * means that the handshake has just been successfully done, and we can + * now proceed to send any available data into our tls object. + */ + void on_tls_activated(); +#endif // BOTAN_FOUND + /** + * Where data is added, when we want to send something to the client. + */ + std::vector<std::string> out_buf; + /** + * DNS resolver + */ + Resolver resolver; + /** + * Keep the details of the addrinfo returned by the resolver that + * triggered a EINPROGRESS error when connect()ing to it, to reuse it + * directly when connect() is called again. + */ + struct addrinfo addrinfo; + struct sockaddr_in6 ai_addr; + socklen_t ai_addrlen; + +protected: + /** + * Where data read from the socket is added until we can extract a full + * and meaningful “message” from it. + * + * TODO: something more efficient than a string. + */ + std::string in_buf; + /** + * Whether we are using TLS on this connection or not. + */ + bool use_tls; + /** + * Provide a buffer in which data can be directly received. This can be + * used to avoid copying data into in_buf before using it. If no buffer + * needs to be provided, nullptr is returned (the default implementation + * does that), in that case our internal in_buf will be used to save the + * data until it can be used by parse_in_buffer(). + */ + virtual void* get_receive_buffer(const size_t size) const; + /** + * Hostname we are connected/connecting to + */ + std::string address; + /** + * Port we are connected/connecting to + */ + std::string port; + + bool connected; + bool connecting; + + bool hostname_resolution_failed; + + /** + * Address to bind the socket to, before calling connect(). + * If empty, it’s equivalent to binding to INADDR_ANY. + */ + std::string bind_addr; + +private: + /** + * Display the resolved IP, just for information purpose. + */ + void display_resolved_ip(struct addrinfo* rp) const; + +#ifdef BOTAN_FOUND + /** + * Botan stuff to manipulate a TLS session. + */ + static Botan::AutoSeeded_RNG rng; + static Botan::TLS::Policy policy; + static Botan::TLS::Session_Manager_In_Memory session_manager; +protected: + BasicCredentialsManager credential_manager; +private: + /** + * We use a unique_ptr because we may not want to create the object at + * all. The Botan::TLS::Client object generates a handshake message and + * calls the output_fn callback with it as soon as it is created. + * Therefore, we do not want to create it if we do not intend to send any + * TLS-encrypted message. We create the object only when needed (for + * example after we have negociated a TLS session using a STARTTLS + * message, or stuf like that). + * + * See start_tls for the method where this object is created. + */ + std::unique_ptr<Botan::TLS::Client> tls; + /** + * An additional buffer to keep data that the user wants to send, but + * cannot because the handshake is not done. + */ + std::string pre_buf; +#endif // BOTAN_FOUND +}; + + + diff --git a/louloulibs/utils/encoding.cpp b/louloulibs/utils/encoding.cpp new file mode 100644 index 0000000..507f38a --- /dev/null +++ b/louloulibs/utils/encoding.cpp @@ -0,0 +1,258 @@ +#include <utils/encoding.hpp> + +#include <utils/scopeguard.hpp> + +#include <stdexcept> + +#include <assert.h> +#include <string.h> +#include <iconv.h> + +#include <map> +#include <bitset> + +/** + * The UTF-8-encoded character used as a place holder when a character conversion fails. + * This is U+FFFD � "replacement character" + */ +static const char* invalid_char = "\xef\xbf\xbd"; +static const size_t invalid_char_len = 3; + +namespace utils +{ + /** + * Based on http://en.wikipedia.org/wiki/UTF-8#Description + */ + std::size_t get_next_codepoint_size(const unsigned char c) + { + if ((c & 0b11111000) == 0b11110000) // 4 bytes: 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx + return 4; + else if ((c & 0b11110000) == 0b11100000) // 3 bytes: 1110xxx 10xxxxxx 10xxxxxx + return 3; + else if ((c & 0b11100000) == 0b11000000) // 2 bytes: 110xxxxx 10xxxxxx + return 2; + return 1; // 1 byte: 0xxxxxxx + } + + bool is_valid_utf8(const char* s) + { + if (!s) + return false; + + const unsigned char* str = reinterpret_cast<const unsigned char*>(s); + + while (*str) + { + const auto codepoint_size = get_next_codepoint_size(str[0]); + if (codepoint_size == 4) + { + if (!str[1] || !str[2] || !str[3] + || ((str[1] & 0b11000000) != 0b10000000) + || ((str[2] & 0b11000000) != 0b10000000) + || ((str[3] & 0b11000000) != 0b10000000)) + return false; + } + else if (codepoint_size == 3) + { + if (!str[1] || !str[2] + || ((str[1] & 0b11000000) != 0b10000000) + || ((str[2] & 0b11000000) != 0b10000000)) + return false; + } + else if (codepoint_size == 2) + { + if (!str[1] || + ((str[1] & 0b11000000) != 0b10000000)) + return false; + } + else if ((str[0] & 0b10000000) != 0) + return false; + str += codepoint_size; + } + return true; + } + + std::string remove_invalid_xml_chars(const std::string& original) + { + // The given string MUST be a valid utf-8 string + unsigned char* res = new unsigned char[original.size()]; + ScopeGuard sg([&res]() { delete[] res;}); + + // pointer where we write valid chars + unsigned char* r = res; + + const unsigned char* str = reinterpret_cast<const unsigned char*>(original.c_str()); + std::bitset<20> codepoint; + + while (*str) + { + // 4 bytes: 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx + if ((str[0] & 0b11111000) == 0b11110000) + { + codepoint = ((str[0] & 0b00000111) << 18); + codepoint |= ((str[1] & 0b00111111) << 12); + codepoint |= ((str[2] & 0b00111111) << 6 ); + codepoint |= ((str[3] & 0b00111111) << 0 ); + if (codepoint.to_ulong() <= 0x10FFFF) + { + ::memcpy(r, str, 4); + r += 4; + } + str += 4; + } + // 3 bytes: 1110xxx 10xxxxxx 10xxxxxx + else if ((str[0] & 0b11110000) == 0b11100000) + { + codepoint = ((str[0] & 0b00001111) << 12); + codepoint |= ((str[1] & 0b00111111) << 6); + codepoint |= ((str[2] & 0b00111111) << 0 ); + if (codepoint.to_ulong() <= 0xD7FF || + (codepoint.to_ulong() >= 0xE000 && codepoint.to_ulong() <= 0xFFFD)) + { + ::memcpy(r, str, 3); + r += 3; + } + str += 3; + } + // 2 bytes: 110xxxxx 10xxxxxx + else if (((str[0]) & 0b11100000) == 0b11000000) + { + // All 2 bytes char are valid, don't even bother calculating + // the codepoint + ::memcpy(r, str, 2); + r += 2; + str += 2; + } + // 1 byte: 0xxxxxxx + else if ((str[0] & 0b10000000) == 0) + { + codepoint = ((str[0] & 0b01111111)); + if (codepoint.to_ulong() == 0x09 || + codepoint.to_ulong() == 0x0A || + codepoint.to_ulong() == 0x0D || + codepoint.to_ulong() >= 0x20) + { + ::memcpy(r, str, 1); + r += 1; + } + str += 1; + } + else + throw std::runtime_error("Invalid UTF-8 passed to remove_invalid_xml_chars"); + } + return std::string(reinterpret_cast<char*>(res), r-res); + } + + std::string convert_to_utf8(const std::string& str, const char* charset) + { + std::string res; + + const iconv_t cd = iconv_open("UTF-8", charset); + if (cd == (iconv_t)-1) + throw std::runtime_error("Cannot convert into UTF-8"); + + // Make sure cd is always closed when we leave this function + ScopeGuard sg([&]{ iconv_close(cd); }); + + size_t inbytesleft = str.size(); + + // iconv will not attempt to modify this buffer, but some plateform + // require a char** anyway +#ifdef ICONV_SECOND_ARGUMENT_IS_CONST + const char* inbuf_ptr = str.c_str(); +#else + char* inbuf_ptr = const_cast<char*>(str.c_str()); +#endif + + size_t outbytesleft = str.size() * 4; + char* outbuf = new char[outbytesleft]; + char* outbuf_ptr = outbuf; + + // Make sure outbuf is always deleted when we leave this function + sg.add_callback([&]{ delete[] outbuf; }); + + bool done = false; + while (done == false) + { + size_t error = iconv(cd, &inbuf_ptr, &inbytesleft, &outbuf_ptr, &outbytesleft); + if ((size_t)-1 == error) + { + switch (errno) + { + case EILSEQ: + // Invalid byte found. Insert a placeholder instead of the + // converted character, jump one byte and continue + memcpy(outbuf_ptr, invalid_char, invalid_char_len); + outbuf_ptr += invalid_char_len; + inbytesleft--; + inbuf_ptr++; + break; + case EINVAL: + // A multibyte sequence is not terminated, but we can't + // provide any more data, so we just add a placeholder to + // indicate that the character is not properly converted, + // and we stop the conversion + memcpy(outbuf_ptr, invalid_char, invalid_char_len); + outbuf_ptr += invalid_char_len; + outbuf_ptr++; + done = true; + break; + case E2BIG: + // This should never happen + done = true; + break; + default: + // This should happen even neverer + done = true; + break; + } + } + else + { + // The conversion finished without any error, stop converting + done = true; + } + } + // Terminate the converted buffer, and copy that buffer it into the + // string we return + *outbuf_ptr = '\0'; + res = outbuf; + return res; + } + +} + +namespace xep0106 +{ + static const std::map<const char, const std::string> encode_map = { + {' ', "\\20"}, + {'"', "\\22"}, + {'&', "\\26"}, + {'\'',"\\27"}, + {'/', "\\2f"}, + {':', "\\3a"}, + {'<', "\\3c"}, + {'>', "\\3e"}, + {'@', "\\40"}, + }; + + void decode(std::string& s) + { + std::string::size_type pos; + for (const auto& pair: encode_map) + while ((pos = s.find(pair.second)) != std::string::npos) + s.replace(pos, pair.second.size(), + 1, pair.first); + } + + void encode(std::string& s) + { + std::string::size_type pos; + while ((pos = s.find_first_of(" \"&'/:<>@")) != std::string::npos) + { + auto it = encode_map.find(s[pos]); + assert(it != encode_map.end()); + s.replace(pos, 1, it->second); + } + } +} diff --git a/louloulibs/utils/encoding.hpp b/louloulibs/utils/encoding.hpp new file mode 100644 index 0000000..586edd8 --- /dev/null +++ b/louloulibs/utils/encoding.hpp @@ -0,0 +1,43 @@ +#pragma once + + +#include <string> + +namespace utils +{ + /** + * Return the size, in bytes, of the next UTF-8 codepoint, based on + * the given char. + */ + std::size_t get_next_codepoint_size(const unsigned char c); + /** + * Returns true if the given null-terminated string is valid utf-8. + * + * Based on http://en.wikipedia.org/wiki/UTF-8#Description + */ + bool is_valid_utf8(const char* s); + /** + * Remove all invalid codepoints from the given utf-8-encoded string. + * The value returned is a copy of the string, without the removed chars. + * + * See http://www.w3.org/TR/xml/#charsets for the list of valid characters + * in XML. + */ + std::string remove_invalid_xml_chars(const std::string& original); + /** + * Convert the given string (encoded is "encoding") into valid utf-8. + * If some decoding fails, insert an utf-8 placeholder character instead. + */ + std::string convert_to_utf8(const std::string& str, const char* encoding); +} + +namespace xep0106 +{ + /** + * Decode and encode inplace. + */ + void decode(std::string&); + void encode(std::string&); +} + + diff --git a/louloulibs/utils/revstr.cpp b/louloulibs/utils/revstr.cpp new file mode 100644 index 0000000..87fd801 --- /dev/null +++ b/louloulibs/utils/revstr.cpp @@ -0,0 +1,9 @@ +#include <utils/revstr.hpp> + +namespace utils +{ + std::string revstr(const std::string& original) + { + return {original.rbegin(), original.rend()}; + } +} diff --git a/louloulibs/utils/revstr.hpp b/louloulibs/utils/revstr.hpp new file mode 100644 index 0000000..8e521ea --- /dev/null +++ b/louloulibs/utils/revstr.hpp @@ -0,0 +1,11 @@ +#pragma once + + +#include <string> + +namespace utils +{ + std::string revstr(const std::string& original); +} + + diff --git a/louloulibs/utils/scopeguard.hpp b/louloulibs/utils/scopeguard.hpp new file mode 100644 index 0000000..ee1e2ef --- /dev/null +++ b/louloulibs/utils/scopeguard.hpp @@ -0,0 +1,89 @@ +#pragma once + +#include <functional> +#include <vector> + +/** + * A class to be used to make sure some functions are called when the scope + * is left, because they will be called in the ScopeGuard's destructor. It + * can for example be used to delete some pointer whenever any exception is + * called. Example: + + * { + * ScopeGuard scope; + * int* number = new int(2); + * scope.add_callback([number]() { delete number; }); + * // Do some other stuff with the number. But these stuff might throw an exception: + * throw std::runtime_error("Some error not caught here, but in our caller"); + * return true; + * } + + * In this example, our pointer will always be deleted, even when the + * exception is thrown. If we want the functions to be called only when the + * scope is left because of an unexpected exception, we can use + * ScopeGuard::disable(); + */ + +namespace utils +{ + +class ScopeGuard +{ +public: + /** + * The constructor can take a callback. But additional callbacks can be + * added later with add_callback() + */ + explicit ScopeGuard(std::function<void()>&& func): + enabled(true) + { + this->add_callback(std::move(func)); + } + + ScopeGuard(const ScopeGuard&) = delete; + ScopeGuard& operator=(ScopeGuard&&) = delete; + ScopeGuard(ScopeGuard&&) = delete; + ScopeGuard& operator=(const ScopeGuard&) = delete; + + /** + * default constructor, the scope guard is enabled but empty, use + * add_callback() + */ + explicit ScopeGuard(): + enabled(true) + { + } + /** + * Call all callbacks in the desctructor, unless it has been disabled. + */ + ~ScopeGuard() + { + if (this->enabled) + for (auto& func: this->callbacks) + func(); + } + /** + * Add a callback to be called in our destructor, one scope guard can be + * used for more than one task, if needed. + */ + void add_callback(std::function<void()>&& func) + { + this->callbacks.emplace_back(std::move(func)); + } + /** + * Disable that scope guard, nothing will be done when the scope is + * exited. + */ + void disable() + { + this->enabled = false; + } + +private: + bool enabled; + std::vector<std::function<void()>> callbacks; + +}; + +} + diff --git a/louloulibs/utils/sha1.cpp b/louloulibs/utils/sha1.cpp new file mode 100644 index 0000000..76476df --- /dev/null +++ b/louloulibs/utils/sha1.cpp @@ -0,0 +1,154 @@ +/* This code is public-domain - it is based on libcrypt + * placed in the public domain by Wei Dai and other contributors. + */ + +#include "sha1.hpp" + +#define SHA1_K0 0x5a827999 +#define SHA1_K20 0x6ed9eba1 +#define SHA1_K40 0x8f1bbcdc +#define SHA1_K60 0xca62c1d6 + +const uint8_t sha1InitState[] = { + 0x01,0x23,0x45,0x67, // H0 + 0x89,0xab,0xcd,0xef, // H1 + 0xfe,0xdc,0xba,0x98, // H2 + 0x76,0x54,0x32,0x10, // H3 + 0xf0,0xe1,0xd2,0xc3 // H4 +}; + +void sha1_init(sha1nfo *s) { + memcpy(s->state.b,sha1InitState,HASH_LENGTH); + s->byteCount = 0; + s->bufferOffset = 0; +} + +uint32_t sha1_rol32(uint32_t number, uint8_t bits) { + return ((number << bits) | (number >> (32-bits))); +} + +void sha1_hashBlock(sha1nfo *s) { + uint8_t i; + uint32_t a,b,c,d,e,t; + + a=s->state.w[0]; + b=s->state.w[1]; + c=s->state.w[2]; + d=s->state.w[3]; + e=s->state.w[4]; + for (i=0; i<80; i++) { + if (i>=16) { + t = s->buffer.w[(i+13)&15] ^ s->buffer.w[(i+8)&15] ^ s->buffer.w[(i+2)&15] ^ s->buffer.w[i&15]; + s->buffer.w[i&15] = sha1_rol32(t,1); + } + if (i<20) { + t = (d ^ (b & (c ^ d))) + SHA1_K0; + } else if (i<40) { + t = (b ^ c ^ d) + SHA1_K20; + } else if (i<60) { + t = ((b & c) | (d & (b | c))) + SHA1_K40; + } else { + t = (b ^ c ^ d) + SHA1_K60; + } + t+=sha1_rol32(a,5) + e + s->buffer.w[i&15]; + e=d; + d=c; + c=sha1_rol32(b,30); + b=a; + a=t; + } + s->state.w[0] += a; + s->state.w[1] += b; + s->state.w[2] += c; + s->state.w[3] += d; + s->state.w[4] += e; +} + +void sha1_addUncounted(sha1nfo *s, uint8_t data) { + s->buffer.b[s->bufferOffset ^ 3] = data; + s->bufferOffset++; + if (s->bufferOffset == BLOCK_LENGTH) { + sha1_hashBlock(s); + s->bufferOffset = 0; + } +} + +void sha1_writebyte(sha1nfo *s, uint8_t data) { + ++s->byteCount; + sha1_addUncounted(s, data); +} + +void sha1_write(sha1nfo *s, const char *data, size_t len) { + for (;len--;) sha1_writebyte(s, (uint8_t) *data++); +} + +void sha1_pad(sha1nfo *s) { + // Implement SHA-1 padding (fips180-2 §5.1.1) + + // Pad with 0x80 followed by 0x00 until the end of the block + sha1_addUncounted(s, 0x80); + while (s->bufferOffset != 56) sha1_addUncounted(s, 0x00); + + // Append length in the last 8 bytes + sha1_addUncounted(s, 0); // We're only using 32 bit lengths + sha1_addUncounted(s, 0); // But SHA-1 supports 64 bit lengths + sha1_addUncounted(s, 0); // So zero pad the top bits + sha1_addUncounted(s, s->byteCount >> 29); // Shifting to multiply by 8 + sha1_addUncounted(s, s->byteCount >> 21); // as SHA-1 supports bitstreams as well as + sha1_addUncounted(s, s->byteCount >> 13); // byte. + sha1_addUncounted(s, s->byteCount >> 5); + sha1_addUncounted(s, s->byteCount << 3); +} + +uint8_t* sha1_result(sha1nfo *s) { + int i; + // Pad to complete the last block + sha1_pad(s); + + // Swap byte order back + for (i=0; i<5; i++) { + uint32_t a,b; + a=s->state.w[i]; + b=a<<24; + b|=(a<<8) & 0x00ff0000; + b|=(a>>8) & 0x0000ff00; + b|=a>>24; + s->state.w[i]=b; + } + + // Return pointer to hash (20 characters) + return s->state.b; +} + +#define HMAC_IPAD 0x36 +#define HMAC_OPAD 0x5c + +void sha1_initHmac(sha1nfo *s, const uint8_t* key, int keyLength) { + uint8_t i; + memset(s->keyBuffer, 0, BLOCK_LENGTH); + if (keyLength > BLOCK_LENGTH) { + // Hash long keys + sha1_init(s); + for (;keyLength--;) sha1_writebyte(s, *key++); + memcpy(s->keyBuffer, sha1_result(s), HASH_LENGTH); + } else { + // Block length keys are used as is + memcpy(s->keyBuffer, key, keyLength); + } + // Start inner hash + sha1_init(s); + for (i=0; i<BLOCK_LENGTH; i++) { + sha1_writebyte(s, s->keyBuffer[i] ^ HMAC_IPAD); + } +} + +uint8_t* sha1_resultHmac(sha1nfo *s) { + uint8_t i; + // Complete inner hash + memcpy(s->innerHash,sha1_result(s),HASH_LENGTH); + // Calculate outer hash + sha1_init(s); + for (i=0; i<BLOCK_LENGTH; i++) sha1_writebyte(s, s->keyBuffer[i] ^ HMAC_OPAD); + for (i=0; i<HASH_LENGTH; i++) sha1_writebyte(s, s->innerHash[i]); + return sha1_result(s); +} diff --git a/louloulibs/utils/sha1.hpp b/louloulibs/utils/sha1.hpp new file mode 100644 index 0000000..d02de75 --- /dev/null +++ b/louloulibs/utils/sha1.hpp @@ -0,0 +1,35 @@ +/* This code is public-domain - it is based on libcrypt + * placed in the public domain by Wei Dai and other contributors. + */ + +#include <stdint.h> +#include <string.h> + +#define HASH_LENGTH 20 +#define BLOCK_LENGTH 64 + +union _buffer { + uint8_t b[BLOCK_LENGTH]; + uint32_t w[BLOCK_LENGTH/4]; +}; + +union _state { + uint8_t b[HASH_LENGTH]; + uint32_t w[HASH_LENGTH/4]; +}; + +typedef struct sha1nfo { + union _buffer buffer; + uint8_t bufferOffset; + union _state state; + uint32_t byteCount; + uint8_t keyBuffer[BLOCK_LENGTH]; + uint8_t innerHash[HASH_LENGTH]; +} sha1nfo; + +void sha1_init(sha1nfo *s); +void sha1_writebyte(sha1nfo *s, uint8_t data); +void sha1_write(sha1nfo *s, const char *data, size_t len); +uint8_t* sha1_result(sha1nfo *s); +void sha1_initHmac(sha1nfo *s, const uint8_t* key, int keyLength); +uint8_t* sha1_resultHmac(sha1nfo *s); diff --git a/louloulibs/utils/split.cpp b/louloulibs/utils/split.cpp new file mode 100644 index 0000000..80f8dae --- /dev/null +++ b/louloulibs/utils/split.cpp @@ -0,0 +1,19 @@ +#include <utils/split.hpp> +#include <sstream> + +namespace utils +{ + std::vector<std::string> split(const std::string& s, const char delim, const bool allow_empty) + { + std::vector<std::string> ret; + std::stringstream ss(s); + std::string item; + while (std::getline(ss, item, delim)) + { + if (item.empty() && !allow_empty) + continue ; + ret.emplace_back(std::move(item)); + } + return ret; + } +} diff --git a/louloulibs/utils/split.hpp b/louloulibs/utils/split.hpp new file mode 100644 index 0000000..3755ef8 --- /dev/null +++ b/louloulibs/utils/split.hpp @@ -0,0 +1,12 @@ +#pragma once + + +#include <string> +#include <vector> + +namespace utils +{ + std::vector<std::string> split(const std::string &s, const char delim, const bool allow_empty=true); +} + + diff --git a/louloulibs/utils/string.cpp b/louloulibs/utils/string.cpp new file mode 100644 index 0000000..635e71a --- /dev/null +++ b/louloulibs/utils/string.cpp @@ -0,0 +1,28 @@ +#include <utils/string.hpp> +#include <utils/encoding.hpp> + +bool to_bool(const std::string& val) +{ + return (val == "1" || val == "true"); +} + +std::vector<std::string> cut(const std::string& val, const std::size_t size) +{ + std::vector<std::string> res; + std::string::size_type pos = 0; + while (pos < val.size()) + { + // Get the number of chars, <= size, that contain only whole + // UTF-8 codepoints. + std::size_t s = 0; + auto codepoint_size = utils::get_next_codepoint_size(val[pos + s]); + while (s + codepoint_size <= size && pos + s < val.size()) + { + s += codepoint_size; + codepoint_size = utils::get_next_codepoint_size(val[pos + s]); + } + res.emplace_back(val.substr(pos, s)); + pos += s; + } + return res; +} diff --git a/louloulibs/utils/string.hpp b/louloulibs/utils/string.hpp new file mode 100644 index 0000000..84ba101 --- /dev/null +++ b/louloulibs/utils/string.hpp @@ -0,0 +1,10 @@ +#pragma once + + +#include <vector> +#include <string> + +bool to_bool(const std::string& val); +std::vector<std::string> cut(const std::string& val, const std::size_t size); + + diff --git a/louloulibs/utils/timed_events.cpp b/louloulibs/utils/timed_events.cpp new file mode 100644 index 0000000..68d009c --- /dev/null +++ b/louloulibs/utils/timed_events.cpp @@ -0,0 +1,49 @@ +#include <utils/timed_events.hpp> + +TimedEvent::TimedEvent(std::chrono::steady_clock::time_point&& time_point, + std::function<void()> callback, const std::string& name): + time_point(std::move(time_point)), + callback(callback), + repeat(false), + repeat_delay(0), + name(name) +{ +} + +TimedEvent::TimedEvent(std::chrono::milliseconds&& duration, + std::function<void()> callback, const std::string& name): + time_point(std::chrono::steady_clock::now() + duration), + callback(callback), + repeat(true), + repeat_delay(std::move(duration)), + name(name) +{ +} + +bool TimedEvent::is_after(const TimedEvent& other) const +{ + return this->is_after(other.time_point); +} + +bool TimedEvent::is_after(const std::chrono::steady_clock::time_point& time_point) const +{ + return this->time_point > time_point; +} + +std::chrono::milliseconds TimedEvent::get_timeout() const +{ + auto now = std::chrono::steady_clock::now(); + if (now > this->time_point) + return std::chrono::milliseconds(0); + return std::chrono::duration_cast<std::chrono::milliseconds>(this->time_point - now); +} + +void TimedEvent::execute() const +{ + this->callback(); +} + +const std::string& TimedEvent::get_name() const +{ + return this->name; +} diff --git a/louloulibs/utils/timed_events.hpp b/louloulibs/utils/timed_events.hpp new file mode 100644 index 0000000..6e28206 --- /dev/null +++ b/louloulibs/utils/timed_events.hpp @@ -0,0 +1,132 @@ +#pragma once + +#include <functional> +#include <string> +#include <chrono> +#include <vector> + +using namespace std::literals::chrono_literals; + +namespace utils { +static constexpr std::chrono::milliseconds no_timeout = std::chrono::milliseconds(-1); +} + +class TimedEventsManager; + +/** + * A callback with an associated date. + */ + +class TimedEvent +{ + friend class TimedEventsManager; +public: + /** + * An event the occurs only once, at the given time_point + */ + explicit TimedEvent(std::chrono::steady_clock::time_point&& time_point, + std::function<void()> callback, const std::string& name=""); + explicit TimedEvent(std::chrono::milliseconds&& duration, + std::function<void()> callback, const std::string& name=""); + + explicit TimedEvent(TimedEvent&&) = default; + TimedEvent& operator=(TimedEvent&&) = default; + ~TimedEvent() = default; + + TimedEvent(const TimedEvent&) = delete; + TimedEvent& operator=(const TimedEvent&) = delete; + + /** + * Whether or not this event happens after the other one. + */ + bool is_after(const TimedEvent& other) const; + bool is_after(const std::chrono::steady_clock::time_point& time_point) const; + /** + * Return the duration difference between now and the event time point. + * If the difference would be negative (i.e. the event is expired), the + * returned value is 0 instead. The value cannot then be negative. + */ + std::chrono::milliseconds get_timeout() const; + void execute() const; + const std::string& get_name() const; + +private: + /** + * The next time point at which the event is executed. + */ + std::chrono::steady_clock::time_point time_point; + /** + * The function to execute. + */ + std::function<void()> callback; + /** + * Whether or not this events repeats itself until it is destroyed. + */ + bool repeat; + /** + * This value is added to the time_point each time the event is executed, + * if repeat is true. Otherwise it is ignored. + */ + std::chrono::milliseconds repeat_delay; + /** + * A name that is used to identify that event. If you want to find your + * event (for example if you want to cancel it), the name should be + * unique. + */ + std::string name; +}; + +/** + * A class managing a list of TimedEvents. + * They are sorted, new events can be added, removed, fetch, etc. + */ + +class TimedEventsManager +{ +public: + ~TimedEventsManager() = default; + + TimedEventsManager(const TimedEventsManager&) = delete; + TimedEventsManager(TimedEventsManager&&) = delete; + TimedEventsManager& operator=(const TimedEventsManager&) = delete; + TimedEventsManager& operator=(TimedEventsManager&&) = delete; + + /** + * Return the unique instance of this class + */ + static TimedEventsManager& instance(); + /** + * Add an event to the list of managed events. The list is sorted after + * this call. + */ + void add_event(TimedEvent&& event); + /** + * Returns the duration, in milliseconds, between now and the next + * available event. If the event is already expired (the duration is + * negative), 0 is returned instead (as in “it's not too late, execute it + * now”) + * Returns a negative value if no event is available. + */ + std::chrono::milliseconds get_timeout() const; + /** + * Execute all the expired events (if their expiration time is exactly + * now, or before now). The event is then removed from the list. If the + * event does repeat, its expiration time is updated and it is reinserted + * in the list at the correct position. + * Returns the number of executed events. + */ + std::size_t execute_expired_events(); + /** + * Remove (and thus cancel) all the timed events with the given name. + * Returns the number of canceled events. + */ + std::size_t cancel(const std::string& name); + /** + * Return the number of managed events. + */ + std::size_t size() const; + +private: + std::vector<TimedEvent> events; + explicit TimedEventsManager() = default; +}; diff --git a/louloulibs/utils/timed_events_manager.cpp b/louloulibs/utils/timed_events_manager.cpp new file mode 100644 index 0000000..67d61fe --- /dev/null +++ b/louloulibs/utils/timed_events_manager.cpp @@ -0,0 +1,73 @@ +#include <utils/timed_events.hpp> + +TimedEventsManager& TimedEventsManager::instance() +{ + static TimedEventsManager inst; + return inst; +} + +void TimedEventsManager::add_event(TimedEvent&& event) +{ + for (auto it = this->events.begin(); it != this->events.end(); ++it) + { + if (it->is_after(event)) + { + this->events.emplace(it, std::move(event)); + return; + } + } + this->events.emplace_back(std::move(event)); +} + +std::chrono::milliseconds TimedEventsManager::get_timeout() const +{ + if (this->events.empty()) + return utils::no_timeout; + return this->events.front().get_timeout(); +} + +std::size_t TimedEventsManager::execute_expired_events() +{ + std::size_t count = 0; + const auto now = std::chrono::steady_clock::now(); + for (auto it = this->events.begin(); it != this->events.end();) + { + if (!it->is_after(now)) + { + TimedEvent copy(std::move(*it)); + it = this->events.erase(it); + ++count; + copy.execute(); + if (copy.repeat) + { + copy.time_point += copy.repeat_delay; + this->add_event(std::move(copy)); + } + continue; + } + else + break; + } + return count; +} + +std::size_t TimedEventsManager::cancel(const std::string& name) +{ + std::size_t res = 0; + for (auto it = this->events.begin(); it != this->events.end();) + { + if (it->get_name() == name) + { + it = this->events.erase(it); + res++; + } + else + ++it; + } + return res; +} + +std::size_t TimedEventsManager::size() const +{ + return this->events.size(); +} diff --git a/louloulibs/utils/tolower.cpp b/louloulibs/utils/tolower.cpp new file mode 100644 index 0000000..3e518bd --- /dev/null +++ b/louloulibs/utils/tolower.cpp @@ -0,0 +1,13 @@ +#include <utils/tolower.hpp> + +namespace utils +{ + std::string tolower(const std::string& original) + { + std::string res; + res.reserve(original.size()); + for (const char c: original) + res += static_cast<char>(std::tolower(c)); + return res; + } +} diff --git a/louloulibs/utils/tolower.hpp b/louloulibs/utils/tolower.hpp new file mode 100644 index 0000000..650e05d --- /dev/null +++ b/louloulibs/utils/tolower.hpp @@ -0,0 +1,11 @@ +#pragma once + + +#include <string> + +namespace utils +{ + std::string tolower(const std::string& original); +} + + diff --git a/louloulibs/utils/xdg.cpp b/louloulibs/utils/xdg.cpp new file mode 100644 index 0000000..48212a1 --- /dev/null +++ b/louloulibs/utils/xdg.cpp @@ -0,0 +1,29 @@ +#include <utils/xdg.hpp> +#include <cstdlib> + +#include "louloulibs.h" + +std::string xdg_path(const std::string& filename, const char* env_var) +{ + const char* xdg_home = ::getenv(env_var); + if (xdg_home && xdg_home[0] == '/') + return std::string{xdg_home} + "/" PROJECT_NAME "/" + filename; + else + { + const char* home = ::getenv("HOME"); + if (home) + return std::string{home} + "/" ".config" "/" PROJECT_NAME "/" + filename; + else + return filename; + } +} + +std::string xdg_config_path(const std::string& filename) +{ + return xdg_path(filename, "XDG_CONFIG_HOME"); +} + +std::string xdg_data_path(const std::string& filename) +{ + return xdg_path(filename, "XDG_DATA_HOME"); +} diff --git a/louloulibs/utils/xdg.hpp b/louloulibs/utils/xdg.hpp new file mode 100644 index 0000000..56e11da --- /dev/null +++ b/louloulibs/utils/xdg.hpp @@ -0,0 +1,14 @@ +#pragma once + + +#include <string> + +/** + * Returns a path for the given filename, according to the XDG base + * directory specification, see + * http://standards.freedesktop.org/basedir-spec/basedir-spec-latest.html + */ +std::string xdg_config_path(const std::string& filename); +std::string xdg_data_path(const std::string& filename); + + diff --git a/louloulibs/xmpp/adhoc_command.cpp b/louloulibs/xmpp/adhoc_command.cpp new file mode 100644 index 0000000..99701d7 --- /dev/null +++ b/louloulibs/xmpp/adhoc_command.cpp @@ -0,0 +1,89 @@ +#include <xmpp/adhoc_command.hpp> +#include <xmpp/xmpp_component.hpp> +#include <utils/reload.hpp> + +using namespace std::string_literals; + +AdhocCommand::AdhocCommand(std::vector<AdhocStep>&& callbacks, const std::string& name, const bool admin_only): + name(name), + callbacks(std::move(callbacks)), + admin_only(admin_only) +{ +} + +bool AdhocCommand::is_admin_only() const +{ + return this->admin_only; +} + +void PingStep1(XmppComponent&, AdhocSession&, XmlNode& command_node) +{ + XmlNode note("note"); + note["type"] = "info"; + note.set_inner("Pong"); + command_node.add_child(std::move(note)); +} + +void HelloStep1(XmppComponent&, AdhocSession&, XmlNode& command_node) +{ + XmlNode x("jabber:x:data:x"); + x["type"] = "form"; + XmlNode title("title"); + title.set_inner("Configure your name."); + x.add_child(std::move(title)); + XmlNode instructions("instructions"); + instructions.set_inner("Please provide your name."); + x.add_child(std::move(instructions)); + XmlNode name_field("field"); + name_field["var"] = "name"; + name_field["type"] = "text-single"; + name_field["label"] = "Your name"; + XmlNode required("required"); + name_field.add_child(std::move(required)); + x.add_child(std::move(name_field)); + command_node.add_child(std::move(x)); +} + +void HelloStep2(XmppComponent&, AdhocSession& session, XmlNode& command_node) +{ + // Find out if the name was provided in the form. + if (const XmlNode* x = command_node.get_child("x", "jabber:x:data")) + { + const XmlNode* name_field = nullptr; + for (const XmlNode* field: x->get_children("field", "jabber:x:data")) + if (field->get_tag("var") == "name") + { + name_field = field; + break; + } + if (name_field) + { + if (const XmlNode* value = name_field->get_child("value", "jabber:x:data")) + { + XmlNode note("note"); + note["type"] = "info"; + note.set_inner("Hello "s + value->get_inner() + "!"s); + command_node.delete_all_children(); + command_node.add_child(std::move(note)); + return; + } + } + } + command_node.delete_all_children(); + XmlNode error(ADHOC_NS":error"); + error["type"] = "modify"; + XmlNode condition(STANZA_NS":bad-request"); + error.add_child(std::move(condition)); + command_node.add_child(std::move(error)); + session.terminate(); +} + +void Reload(XmppComponent&, AdhocSession&, XmlNode& command_node) +{ + ::reload_process(); + command_node.delete_all_children(); + XmlNode note("note"); + note["type"] = "info"; + note.set_inner("Configuration reloaded."); + command_node.add_child(std::move(note)); +} diff --git a/louloulibs/xmpp/adhoc_command.hpp b/louloulibs/xmpp/adhoc_command.hpp new file mode 100644 index 0000000..7c4de47 --- /dev/null +++ b/louloulibs/xmpp/adhoc_command.hpp @@ -0,0 +1,44 @@ +#pragma once + +/** + * Describe an ad-hoc command. + * + * Can only have zero or one step for now. When execution is requested, it + * can return a result immediately, or provide a form to be filled, and + * provide a result once the filled form is received. + */ + +#include <xmpp/adhoc_session.hpp> + +#include <functional> +#include <string> + +class AdhocCommand +{ + friend class AdhocSession; +public: + AdhocCommand(std::vector<AdhocStep>&& callback, const std::string& name, const bool admin_only); + ~AdhocCommand() = default; + AdhocCommand(const AdhocCommand&) = default; + AdhocCommand(AdhocCommand&&) = default; + AdhocCommand& operator=(AdhocCommand&&) = delete; + AdhocCommand& operator=(const AdhocCommand&) = delete; + + const std::string name; + + bool is_admin_only() const; + +private: + /** + * A command may have one or more steps. Each step is a different + * callback, inserting things into a <command/> XmlNode and calling + * methods of an AdhocSession. + */ + std::vector<AdhocStep> callbacks; + const bool admin_only; +}; + +void PingStep1(XmppComponent&, AdhocSession& session, XmlNode& command_node); +void HelloStep1(XmppComponent&, AdhocSession& session, XmlNode& command_node); +void HelloStep2(XmppComponent&, AdhocSession& session, XmlNode& command_node); +void Reload(XmppComponent&, AdhocSession& session, XmlNode& command_node); diff --git a/louloulibs/xmpp/adhoc_commands_handler.cpp b/louloulibs/xmpp/adhoc_commands_handler.cpp new file mode 100644 index 0000000..17c4e67 --- /dev/null +++ b/louloulibs/xmpp/adhoc_commands_handler.cpp @@ -0,0 +1,123 @@ +#include <xmpp/adhoc_commands_handler.hpp> +#include <xmpp/xmpp_component.hpp> + +#include <utils/timed_events.hpp> +#include <logger/logger.hpp> +#include <config/config.hpp> +#include <xmpp/jid.hpp> + +#include <iostream> + +using namespace std::string_literals; + +const std::map<const std::string, const AdhocCommand>& AdhocCommandsHandler::get_commands() const +{ + return this->commands; +} + +std::map<const std::string, const AdhocCommand>& AdhocCommandsHandler::get_commands() +{ + return this->commands; +} + +XmlNode AdhocCommandsHandler::handle_request(const std::string& executor_jid, const std::string& to, XmlNode command_node) +{ + std::string action = command_node.get_tag("action"); + if (action.empty()) + action = "execute"; + command_node.del_tag("action"); + + Jid jid(executor_jid); + + const std::string node = command_node.get_tag("node"); + auto command_it = this->commands.find(node); + if (command_it == this->commands.end()) + { + XmlNode error(ADHOC_NS":error"); + error["type"] = "cancel"; + XmlNode condition(STANZA_NS":item-not-found"); + error.add_child(std::move(condition)); + command_node.add_child(std::move(error)); + } + else if (command_it->second.is_admin_only() && + Config::get("admin", "") != jid.local + "@" + jid.domain) + { + XmlNode error(ADHOC_NS":error"); + error["type"] = "cancel"; + XmlNode condition(STANZA_NS":forbidden"); + error.add_child(std::move(condition)); + command_node.add_child(std::move(error)); + } + else + { + std::string sessionid = command_node.get_tag("sessionid"); + if (sessionid.empty()) + { // create a new session, with a new id + sessionid = XmppComponent::next_id(); + command_node["sessionid"] = sessionid; + this->sessions.emplace(std::piecewise_construct, + std::forward_as_tuple(sessionid, executor_jid), + std::forward_as_tuple(command_it->second, executor_jid, to)); + TimedEventsManager::instance().add_event(TimedEvent(std::chrono::steady_clock::now() + 3600s, + std::bind(&AdhocCommandsHandler::remove_session, this, sessionid, executor_jid), + "adhocsession"s + sessionid + executor_jid)); + } + auto session_it = this->sessions.find(std::make_pair(sessionid, executor_jid)); + if (session_it == this->sessions.end()) + { + XmlNode error(ADHOC_NS":error"); + error["type"] = "modify"; + XmlNode condition(STANZA_NS":bad-request"); + error.add_child(std::move(condition)); + command_node.add_child(std::move(error)); + } + else if (action == "execute" || action == "next" || action == "complete") + { + // execute the step + AdhocSession& session = session_it->second; + const AdhocStep& step = session.get_next_step(); + step(this->xmpp_component, session, command_node); + if (session.remaining_steps() == 0 || + session.is_terminated()) + { + this->sessions.erase(session_it); + command_node["status"] = "completed"; + TimedEventsManager::instance().cancel("adhocsession"s + sessionid + executor_jid); + } + else + { + command_node["status"] = "executing"; + XmlNode actions("actions"); + XmlNode next("next"); + actions.add_child(std::move(next)); + command_node.add_child(std::move(actions)); + } + } + else if (action == "cancel") + { + this->sessions.erase(session_it); + command_node["status"] = "canceled"; + TimedEventsManager::instance().cancel("adhocsession"s + sessionid + executor_jid); + } + else // unsupported action + { + XmlNode error(ADHOC_NS":error"); + error["type"] = "modify"; + XmlNode condition(STANZA_NS":bad-request"); + error.add_child(std::move(condition)); + command_node.add_child(std::move(error)); + } + } + return command_node; +} + +void AdhocCommandsHandler::remove_session(const std::string& session_id, const std::string& initiator_jid) +{ + auto session_it = this->sessions.find(std::make_pair(session_id, initiator_jid)); + if (session_it != this->sessions.end()) + { + this->sessions.erase(session_it); + return ; + } + log_error("Tried to remove ad-hoc session for [", session_id, ", ", initiator_jid, "] but none found"); +} diff --git a/louloulibs/xmpp/adhoc_commands_handler.hpp b/louloulibs/xmpp/adhoc_commands_handler.hpp new file mode 100644 index 0000000..91eb5bd --- /dev/null +++ b/louloulibs/xmpp/adhoc_commands_handler.hpp @@ -0,0 +1,71 @@ +#pragma once + +/** + * Manage a list of available AdhocCommands and the list of ongoing + * AdhocCommandSessions. + */ + +#include <xmpp/adhoc_command.hpp> +#include <xmpp/xmpp_stanza.hpp> + +#include <utility> +#include <string> +#include <map> + +class AdhocCommandsHandler +{ +public: + explicit AdhocCommandsHandler(XmppComponent& xmpp_component): + xmpp_component(xmpp_component), + commands{} + { } + ~AdhocCommandsHandler() = default; + + AdhocCommandsHandler(const AdhocCommandsHandler&) = delete; + AdhocCommandsHandler(AdhocCommandsHandler&&) = delete; + AdhocCommandsHandler& operator=(const AdhocCommandsHandler&) = delete; + AdhocCommandsHandler& operator=(AdhocCommandsHandler&&) = delete; + + /** + * Returns the list of available commands. + */ + const std::map<const std::string, const AdhocCommand>& get_commands() const; + /** + * This one can be used to add new commands. + */ + std::map<const std::string, const AdhocCommand>& get_commands(); + /** + * Find the requested command, create a new session or use an existing + * one, and process the request (provide a new form, an error, or a + * result). + * + * Returns a (moved) XmlNode that will be inserted in the iq response. It + * should be a <command/> node containing one or more useful children. If + * it contains an <error/> node, the iq response will have an error type. + * + * Takes a copy of the <command/> node so we can actually edit it and use + * it as our return value. + */ + XmlNode handle_request(const std::string& executor_jid, const std::string& to, XmlNode command_node); + /** + * Remove the session from the list. This is done to avoid filling the + * memory with waiting session (for example due to a client that starts + * multi-steps command but never finishes them). + */ + void remove_session(const std::string& session_id, const std::string& initiator_jid); +private: + /** + * To access basically anything in the gateway. + */ + XmppComponent& xmpp_component; + /** + * The list of all available commands. + */ + std::map<const std::string, const AdhocCommand> commands; + /** + * The list of all currently on-going commands. + * + * Of the form: {{session_id, owner_jid}, session}. + */ + std::map<std::pair<const std::string, const std::string>, AdhocSession> sessions; +}; diff --git a/louloulibs/xmpp/adhoc_session.cpp b/louloulibs/xmpp/adhoc_session.cpp new file mode 100644 index 0000000..dda4bea --- /dev/null +++ b/louloulibs/xmpp/adhoc_session.cpp @@ -0,0 +1,35 @@ +#include <xmpp/adhoc_session.hpp> +#include <xmpp/adhoc_command.hpp> + +#include <assert.h> + +AdhocSession::AdhocSession(const AdhocCommand& command, const std::string& owner_jid, + const std::string& to_jid): + command(command), + owner_jid(owner_jid), + to_jid(to_jid), + current_step(0), + terminated(false) +{ +} + +const AdhocStep& AdhocSession::get_next_step() +{ + assert(this->current_step < this->command.callbacks.size()); + return this->command.callbacks[this->current_step++]; +} + +size_t AdhocSession::remaining_steps() const +{ + return this->command.callbacks.size() - this->current_step; +} + +bool AdhocSession::is_terminated() const +{ + return this->terminated; +} + +void AdhocSession::terminate() +{ + this->terminated = true; +} diff --git a/louloulibs/xmpp/adhoc_session.hpp b/louloulibs/xmpp/adhoc_session.hpp new file mode 100644 index 0000000..0de8d13 --- /dev/null +++ b/louloulibs/xmpp/adhoc_session.hpp @@ -0,0 +1,88 @@ +#pragma once + +#include <xmpp/xmpp_stanza.hpp> + +#include <functional> +#include <string> +#include <map> + +class XmppComponent; + +class AdhocCommand; +class AdhocSession; + +/** + * A function executed as an ad-hoc command step. It takes a <command/> + * XmlNode and modifies it accordingly (inserting for example an <error/> + * node, or a data form…). + */ +using AdhocStep = std::function<void(XmppComponent&, AdhocSession&, XmlNode&)>; + +class AdhocSession +{ +public: + explicit AdhocSession(const AdhocCommand& command, const std::string& owner_jid, + const std::string& to_jid); + ~AdhocSession() = default; + + AdhocSession(const AdhocSession&) = delete; + AdhocSession(AdhocSession&&) = delete; + AdhocSession& operator=(const AdhocSession&) = delete; + AdhocSession& operator=(AdhocSession&&) = delete; + + /** + * Return the function to be executed, found in our AdhocCommand, for the + * current_step. And increment the current_step. + */ + const AdhocStep& get_next_step(); + /** + * Return the number of remaining steps. + */ + size_t remaining_steps() const; + /** + * This may be modified by an AdhocStep, to indicate that this session + * should no longer exist, because we encountered an error, and we can't + * execute any more step of it. + */ + void terminate(); + bool is_terminated() const; + std::string get_target_jid() const + { + return this->to_jid; + } + std::string get_owner_jid() const + { + return this->owner_jid; + } + +private: + /** + * A reference of the command concerned by this session. Used for example + * to get the next step of that command, things like that. + */ + const AdhocCommand& command; + /** + * The full JID of the XMPP user that created this session by executing + * the first step of a command. Only that JID must be allowed to access + * this session. + */ + const std::string& owner_jid; + /** + * The 'to' attribute in the request stanza. This is the target of the current session. + */ + const std::string& to_jid; + /** + * The current step we are at. It starts at zero. It is used to index the + * associated AdhocCommand::callbacks vector. + */ + size_t current_step; + bool terminated; + +public: + /** + * A map to store various things that we may want to remember between two + * steps of the same session. A step can insert any value associated to + * any key in there. + */ + std::map<std::string, std::string> vars; +}; diff --git a/louloulibs/xmpp/body.hpp b/louloulibs/xmpp/body.hpp new file mode 100644 index 0000000..068d1a4 --- /dev/null +++ b/louloulibs/xmpp/body.hpp @@ -0,0 +1,12 @@ +#pragma once + + +namespace Xmpp +{ +// Contains: +// - an XMPP-valid UTF-8 body +// - an XML node representing the XHTML-IM body, or null + using body = std::tuple<const std::string, std::unique_ptr<XmlNode>>; +} + + diff --git a/louloulibs/xmpp/jid.cpp b/louloulibs/xmpp/jid.cpp new file mode 100644 index 0000000..7b62f3e --- /dev/null +++ b/louloulibs/xmpp/jid.cpp @@ -0,0 +1,99 @@ +#include <xmpp/jid.hpp> +#include <algorithm> +#include <cstring> +#include <map> + +#include <louloulibs.h> +#ifdef LIBIDN_FOUND + #include <stringprep.h> +#endif + +#include <logger/logger.hpp> + +Jid::Jid(const std::string& jid) +{ + std::string::size_type slash = jid.find('/'); + if (slash != std::string::npos) + { + this->resource = jid.substr(slash + 1); + } + + std::string::size_type at = jid.find('@'); + if (at != std::string::npos && at < slash) + { + this->local = jid.substr(0, at); + at++; + } + else + at = 0; + + this->domain = jid.substr(at, slash - at); +} + +static constexpr size_t max_jid_part_len = 1023; + +std::string jidprep(const std::string& original) +{ +#ifdef LIBIDN_FOUND + using CacheType = std::map<std::string, std::string>; + static CacheType cache; + std::pair<CacheType::iterator, bool> cached = cache.insert({original, {}}); + if (std::get<1>(cached) == false) + { // Insertion failed: the result is already in the cache, return it + return std::get<0>(cached)->second; + } + + const std::string error_msg("Failed to convert " + original + " into a valid JID:"); + Jid jid(original); + + char local[max_jid_part_len] = {}; + memcpy(local, jid.local.data(), std::min(max_jid_part_len, jid.local.size())); + Stringprep_rc rc = static_cast<Stringprep_rc>(::stringprep(local, max_jid_part_len, + static_cast<Stringprep_profile_flags>(0), stringprep_xmpp_nodeprep)); + if (rc != STRINGPREP_OK) + { + log_error(error_msg + stringprep_strerror(rc)); + return ""; + } + + char domain[max_jid_part_len] = {}; + memcpy(domain, jid.domain.data(), std::min(max_jid_part_len, jid.domain.size())); + rc = static_cast<Stringprep_rc>(::stringprep(domain, max_jid_part_len, + static_cast<Stringprep_profile_flags>(0), stringprep_nameprep)); + if (rc != STRINGPREP_OK) + { + log_error(error_msg + stringprep_strerror(rc)); + return ""; + } + std::replace_if(std::begin(domain), domain + ::strlen(domain), + [](const char c) -> bool + { + return !((c >= 'a' && c <= 'z') || c == '-' || + (c >= '0' && c <= '9') || c == '.'); + }, '-'); + + // If there is no resource, stop here + if (jid.resource.empty()) + { + std::get<0>(cached)->second = std::string(local) + "@" + domain; + return std::get<0>(cached)->second; + } + + // Otherwise, also process the resource part + char resource[max_jid_part_len] = {}; + memcpy(resource, jid.resource.data(), std::min(max_jid_part_len, jid.resource.size())); + rc = static_cast<Stringprep_rc>(::stringprep(resource, max_jid_part_len, + static_cast<Stringprep_profile_flags>(0), stringprep_xmpp_resourceprep)); + if (rc != STRINGPREP_OK) + { + log_error(error_msg + stringprep_strerror(rc)); + return ""; + } + std::get<0>(cached)->second = std::string(local) + "@" + domain + "/" + resource; + return std::get<0>(cached)->second; + +#else + (void)original; + return ""; +#endif +} diff --git a/louloulibs/xmpp/jid.hpp b/louloulibs/xmpp/jid.hpp new file mode 100644 index 0000000..08327ef --- /dev/null +++ b/louloulibs/xmpp/jid.hpp @@ -0,0 +1,44 @@ +#pragma once + + +#include <string> + +/** + * Parse a JID into its different subart + */ +class Jid +{ +public: + explicit Jid(const std::string& jid); + + Jid(const Jid&) = delete; + Jid(Jid&&) = delete; + Jid& operator=(const Jid&) = delete; + Jid& operator=(Jid&&) = delete; + + std::string domain; + std::string local; + std::string resource; + + std::string bare() const + { + return this->local + "@" + this->domain; + } + std::string full() const + { + return this->local + "@" + this->domain + "/" + this->resource; + } +}; + +/** + * Prepare the given UTF-8 string according to the XMPP node stringprep + * identifier profile. This is used to send properly-formed JID to the XMPP + * server. + * + * If the stringprep library is not found, we return an empty string. When + * this function is used, the result must always be checked for an empty + * value, and if this is the case it must not be used as a JID. + */ +std::string jidprep(const std::string& original); + + diff --git a/louloulibs/xmpp/roster.cpp b/louloulibs/xmpp/roster.cpp new file mode 100644 index 0000000..a14a384 --- /dev/null +++ b/louloulibs/xmpp/roster.cpp @@ -0,0 +1,21 @@ +#include <xmpp/roster.hpp> + +RosterItem::RosterItem(const std::string& jid, const std::string& name, + std::vector<std::string>& groups): + jid(jid), + name(name), + groups(groups) +{ +} + +RosterItem::RosterItem(const std::string& jid, const std::string& name): + jid(jid), + name(name), + groups{} +{ +} + +void Roster::clear() +{ + this->items.clear(); +} diff --git a/louloulibs/xmpp/roster.hpp b/louloulibs/xmpp/roster.hpp new file mode 100644 index 0000000..aa1b449 --- /dev/null +++ b/louloulibs/xmpp/roster.hpp @@ -0,0 +1,71 @@ +#pragma once + + +#include <algorithm> +#include <string> +#include <vector> + +class RosterItem +{ +public: + RosterItem(const std::string& jid, const std::string& name, + std::vector<std::string>& groups); + RosterItem(const std::string& jid, const std::string& name); + RosterItem() = default; + ~RosterItem() = default; + RosterItem(const RosterItem&) = default; + RosterItem(RosterItem&&) = default; + RosterItem& operator=(const RosterItem&) = default; + RosterItem& operator=(RosterItem&&) = default; + + std::string jid; + std::string name; + std::vector<std::string> groups; + +private: +}; + +/** + * Keep track of the last known stat of a JID's roster + */ +class Roster +{ +public: + Roster() = default; + ~Roster() = default; + + void clear(); + + template <typename... ArgsType> + RosterItem* add_item(ArgsType&&... args) + { + this->items.emplace_back(std::forward<ArgsType>(args)...); + auto it = this->items.end() - 1; + return &*it; + } + RosterItem* get_item(const std::string& jid) + { + auto it = std::find_if(this->items.begin(), this->items.end(), + [this, &jid](const auto& item) + { + return item.jid == jid; + }); + if (it != this->items.end()) + return &*it; + return nullptr; + } + const std::vector<RosterItem>& get_items() const + { + return this->items; + } + +private: + std::vector<RosterItem> items; + + Roster(const Roster&) = delete; + Roster(Roster&&) = delete; + Roster& operator=(const Roster&) = delete; + Roster& operator=(Roster&&) = delete; +}; + + diff --git a/louloulibs/xmpp/xmpp_component.cpp b/louloulibs/xmpp/xmpp_component.cpp new file mode 100644 index 0000000..e87cdf7 --- /dev/null +++ b/louloulibs/xmpp/xmpp_component.cpp @@ -0,0 +1,664 @@ +#include <utils/timed_events.hpp> +#include <utils/scopeguard.hpp> +#include <utils/tolower.hpp> +#include <logger/logger.hpp> + +#include <xmpp/xmpp_component.hpp> +#include <config/config.hpp> +#include <xmpp/jid.hpp> +#include <utils/sha1.hpp> + +#include <stdexcept> +#include <iostream> +#include <set> + +#include <stdio.h> + +#include <uuid.h> + +#include <louloulibs.h> +#ifdef SYSTEMD_FOUND +# include <systemd/sd-daemon.h> +#endif + +using namespace std::string_literals; + +static std::set<std::string> kickable_errors{ + "gone", + "internal-server-error", + "item-not-found", + "jid-malformed", + "recipient-unavailable", + "redirect", + "remote-server-not-found", + "remote-server-timeout", + "service-unavailable", + "malformed-error" + }; + +XmppComponent::XmppComponent(std::shared_ptr<Poller> poller, const std::string& hostname, const std::string& secret): + TCPSocketHandler(poller), + ever_auth(false), + first_connection_try(true), + secret(secret), + authenticated(false), + doc_open(false), + served_hostname(hostname), + stanza_handlers{}, + adhoc_commands_handler(*this) +{ + this->parser.add_stream_open_callback(std::bind(&XmppComponent::on_remote_stream_open, this, + std::placeholders::_1)); + this->parser.add_stanza_callback(std::bind(&XmppComponent::on_stanza, this, + std::placeholders::_1)); + this->parser.add_stream_close_callback(std::bind(&XmppComponent::on_remote_stream_close, this, + std::placeholders::_1)); + this->stanza_handlers.emplace("handshake", + std::bind(&XmppComponent::handle_handshake, this,std::placeholders::_1)); + this->stanza_handlers.emplace("error", + std::bind(&XmppComponent::handle_error, this,std::placeholders::_1)); +} + +void XmppComponent::start() +{ + this->connect(Config::get("xmpp_server_ip", "127.0.0.1"), Config::get("port", "5347"), false); +} + +bool XmppComponent::is_document_open() const +{ + return this->doc_open; +} + +void XmppComponent::send_stanza(const Stanza& stanza) +{ + std::string str = stanza.to_string(); + log_debug("XMPP SENDING: ", str); + this->send_data(std::move(str)); +} + +void XmppComponent::on_connection_failed(const std::string& reason) +{ + this->first_connection_try = false; + log_error("Failed to connect to the XMPP server: ", reason); +#ifdef SYSTEMD_FOUND + sd_notifyf(0, "STATUS=Failed to connect to the XMPP server: %s", reason.data()); +#endif +} + +void XmppComponent::on_connected() +{ + log_info("connected to XMPP server"); + this->first_connection_try = true; + auto data = "<stream:stream to='"s + this->served_hostname + \ + "' xmlns:stream='http://etherx.jabber.org/streams' xmlns='" COMPONENT_NS "'>"; + log_debug("XMPP SENDING: ", data); + this->send_data(std::move(data)); + this->doc_open = true; + // We may have some pending data to send: this happens when we try to send + // some data before we are actually connected. We send that data right now, if any + this->send_pending_data(); +} + +void XmppComponent::on_connection_close(const std::string& error) +{ + if (error.empty()) + log_info("XMPP server closed connection"); + else + log_info("XMPP server closed connection: ", error); +} + +void XmppComponent::parse_in_buffer(const size_t size) +{ + if (!this->in_buf.empty()) + { // This may happen if the parser could not allocate enough space for + // us. We try to feed it the data that was read into our in_buf + // instead. If this fails again we are in trouble. + this->parser.feed(this->in_buf.data(), this->in_buf.size(), false); + this->in_buf.clear(); + } + else + { // Just tell the parser to parse the data that was placed into the + // buffer it provided to us with GetBuffer + this->parser.parse(size, false); + } +} + +void XmppComponent::on_remote_stream_open(const XmlNode& node) +{ + log_debug("XMPP RECEIVING: ", node.to_string()); + this->stream_id = node.get_tag("id"); + if (this->stream_id.empty()) + { + log_error("Error: no attribute 'id' found"); + this->send_stream_error("bad-format", "missing 'id' attribute"); + this->close_document(); + return ; + } + + // Try to authenticate + char digest[HASH_LENGTH * 2 + 1]; + sha1nfo sha1; + sha1_init(&sha1); + sha1_write(&sha1, this->stream_id.data(), this->stream_id.size()); + sha1_write(&sha1, this->secret.data(), this->secret.size()); + const uint8_t* result = sha1_result(&sha1); + for (int i=0; i < HASH_LENGTH; i++) + sprintf(digest + (i*2), "%02x", result[i]); + digest[HASH_LENGTH * 2] = '\0'; + + auto data = "<handshake xmlns='" COMPONENT_NS "'>"s + digest + "</handshake>"; + log_debug("XMPP SENDING: ", data); + this->send_data(std::move(data)); +} + +void XmppComponent::on_remote_stream_close(const XmlNode& node) +{ + log_debug("XMPP RECEIVING: ", node.to_string()); + this->doc_open = false; +} + +void XmppComponent::reset() +{ + this->parser.reset(); +} + +void XmppComponent::on_stanza(const Stanza& stanza) +{ + log_debug("XMPP RECEIVING: ", stanza.to_string()); + std::function<void(const Stanza&)> handler; + try + { + handler = this->stanza_handlers.at(stanza.get_name()); + } + catch (const std::out_of_range& exception) + { + log_warning("No handler for stanza of type ", stanza.get_name()); + return; + } + handler(stanza); +} + +void XmppComponent::send_stream_error(const std::string& name, const std::string& explanation) +{ + XmlNode node("stream:error", nullptr); + XmlNode error(name, nullptr); + error["xmlns"] = STREAM_NS; + if (!explanation.empty()) + error.set_inner(explanation); + node.add_child(std::move(error)); + this->send_stanza(node); +} + +void XmppComponent::send_stanza_error(const std::string& kind, const std::string& to, const std::string& from, + const std::string& id, const std::string& error_type, + const std::string& defined_condition, const std::string& text, + const bool fulljid) +{ + Stanza node(kind); + if (!to.empty()) + node["to"] = to; + if (!from.empty()) + { + if (fulljid) + node["from"] = from; + else + node["from"] = from + "@" + this->served_hostname; + } + if (!id.empty()) + node["id"] = id; + node["type"] = "error"; + XmlNode error("error"); + error["type"] = error_type; + XmlNode inner_error(defined_condition); + inner_error["xmlns"] = STANZA_NS; + error.add_child(std::move(inner_error)); + if (!text.empty()) + { + XmlNode text_node("text"); + text_node["xmlns"] = STANZA_NS; + text_node.set_inner(text); + error.add_child(std::move(text_node)); + } + node.add_child(std::move(error)); + this->send_stanza(node); +} + +void XmppComponent::close_document() +{ + log_debug("XMPP SENDING: </stream:stream>"); + this->send_data("</stream:stream>"); + this->doc_open = false; +} + +void XmppComponent::handle_handshake(const Stanza& stanza) +{ + (void)stanza; + this->authenticated = true; + this->ever_auth = true; + log_info("Authenticated with the XMPP server"); +#ifdef SYSTEMD_FOUND + sd_notify(0, "READY=1"); + // Install an event that sends a keepalive to systemd. If biboumi crashes + // or hangs for too long, systemd will restart it. + uint64_t usec; + if (sd_watchdog_enabled(0, &usec) > 0) + { + TimedEventsManager::instance().add_event(TimedEvent( + std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::microseconds(usec / 2)), + []() { sd_notify(0, "WATCHDOG=1"); })); + } +#endif + this->after_handshake(); +} + +void XmppComponent::handle_error(const Stanza& stanza) +{ + const XmlNode* text = stanza.get_child("text", STREAMS_NS); + std::string error_message("Unspecified error"); + if (text) + error_message = text->get_inner(); + log_error("Stream error received from the XMPP server: ", error_message); +#ifdef SYSTEMD_FOUND + if (!this->ever_auth) + sd_notifyf(0, "STATUS=Failed to authenticate to the XMPP server: %s", error_message.data()); +#endif + +} + +void* XmppComponent::get_receive_buffer(const size_t size) const +{ + return this->parser.get_buffer(size); +} + +void XmppComponent::send_message(const std::string& from, Xmpp::body&& body, const std::string& to, const std::string& type, const bool fulljid) +{ + XmlNode node("message"); + node["to"] = to; + if (fulljid) + node["from"] = from; + else + node["from"] = from + "@" + this->served_hostname; + if (!type.empty()) + node["type"] = type; + XmlNode body_node("body"); + body_node.set_inner(std::get<0>(body)); + node.add_child(std::move(body_node)); + if (std::get<1>(body)) + { + XmlNode html("html"); + html["xmlns"] = XHTMLIM_NS; + // Pass the ownership of the pointer to this xmlnode + html.add_child(std::move(std::get<1>(body))); + node.add_child(std::move(html)); + } + this->send_stanza(node); +} + +void XmppComponent::send_user_join(const std::string& from, + const std::string& nick, + const std::string& realjid, + const std::string& affiliation, + const std::string& role, + const std::string& to, + const bool self) +{ + XmlNode node("presence"); + node["to"] = to; + node["from"] = from + "@" + this->served_hostname + "/" + nick; + + XmlNode x("x"); + x["xmlns"] = MUC_USER_NS; + + XmlNode item("item"); + if (!affiliation.empty()) + item["affiliation"] = affiliation; + if (!role.empty()) + item["role"] = role; + if (!realjid.empty()) + { + const std::string preped_jid = jidprep(realjid); + if (!preped_jid.empty()) + item["jid"] = preped_jid; + } + x.add_child(std::move(item)); + + if (self) + { + XmlNode status("status"); + status["code"] = "110"; + x.add_child(std::move(status)); + } + node.add_child(std::move(x)); + this->send_stanza(node); +} + +void XmppComponent::send_invalid_room_error(const std::string& muc_name, + const std::string& nick, + const std::string& to) +{ + Stanza presence("presence"); + if (!muc_name.empty()) + presence["from"] = muc_name + "@" + this->served_hostname + "/" + nick; + else + presence["from"] = this->served_hostname; + presence["to"] = to; + presence["type"] = "error"; + XmlNode x("x"); + x["xmlns"] = MUC_NS; + presence.add_child(std::move(x)); + XmlNode error("error"); + error["by"] = muc_name + "@" + this->served_hostname; + error["type"] = "cancel"; + XmlNode item_not_found("item-not-found"); + item_not_found["xmlns"] = STANZA_NS; + error.add_child(std::move(item_not_found)); + XmlNode text("text"); + text["xmlns"] = STANZA_NS; + text["xml:lang"] = "en"; + text.set_inner(muc_name + + " is not a valid IRC channel name. A correct room jid is of the form: #<chan>%<server>@" + + this->served_hostname); + error.add_child(std::move(text)); + presence.add_child(std::move(error)); + this->send_stanza(presence); +} + +void XmppComponent::send_invalid_user_error(const std::string& user_name, const std::string& to) +{ + Stanza message("message"); + message["from"] = user_name + "@" + this->served_hostname; + message["to"] = to; + message["type"] = "error"; + XmlNode x("x"); + x["xmlns"] = MUC_NS; + message.add_child(std::move(x)); + XmlNode error("error"); + error["type"] = "cancel"; + XmlNode item_not_found("item-not-found"); + item_not_found["xmlns"] = STANZA_NS; + error.add_child(std::move(item_not_found)); + XmlNode text("text"); + text["xmlns"] = STANZA_NS; + text["xml:lang"] = "en"; + text.set_inner(user_name + + " is not a valid IRC user name. A correct user jid is of the form: <nick>!<server>@" + + this->served_hostname); + error.add_child(std::move(text)); + message.add_child(std::move(error)); + this->send_stanza(message); +} + +void XmppComponent::send_topic(const std::string& from, Xmpp::body&& topic, const std::string& to, const std::string& who) +{ + XmlNode message("message"); + message["to"] = to; + if (who.empty()) + message["from"] = from + "@" + this->served_hostname; + else + message["from"] = from + "@" + this->served_hostname + "/" + who; + message["type"] = "groupchat"; + XmlNode subject("subject"); + subject.set_inner(std::get<0>(topic)); + message.add_child(std::move(subject)); + this->send_stanza(message); +} + +void XmppComponent::send_muc_message(const std::string& muc_name, const std::string& nick, Xmpp::body&& xmpp_body, const std::string& jid_to) +{ + Stanza message("message"); + message["to"] = jid_to; + if (!nick.empty()) + message["from"] = muc_name + "@" + this->served_hostname + "/" + nick; + else // Message from the room itself + message["from"] = muc_name + "@" + this->served_hostname; + message["type"] = "groupchat"; + XmlNode body("body"); + body.set_inner(std::get<0>(xmpp_body)); + message.add_child(std::move(body)); + if (std::get<1>(xmpp_body)) + { + XmlNode html("html"); + html["xmlns"] = XHTMLIM_NS; + // Pass the ownership of the pointer to this xmlnode + html.add_child(std::move(std::get<1>(xmpp_body))); + message.add_child(std::move(html)); + } + this->send_stanza(message); +} + +void XmppComponent::send_muc_leave(const std::string& muc_name, std::string&& nick, Xmpp::body&& message, const std::string& jid_to, const bool self) +{ + Stanza presence("presence"); + presence["to"] = jid_to; + presence["from"] = muc_name + "@" + this->served_hostname + "/" + nick; + presence["type"] = "unavailable"; + const std::string message_str = std::get<0>(message); + XmlNode x("x"); + x["xmlns"] = MUC_USER_NS; + if (self) + { + XmlNode status("status"); + status["code"] = "110"; + x.add_child(std::move(status)); + } + presence.add_child(std::move(x)); + if (!message_str.empty()) + { + XmlNode status("status"); + status.set_inner(message_str); + presence.add_child(std::move(status)); + } + this->send_stanza(presence); +} + +void XmppComponent::send_nick_change(const std::string& muc_name, + const std::string& old_nick, + const std::string& new_nick, + const std::string& affiliation, + const std::string& role, + const std::string& jid_to, + const bool self) +{ + Stanza presence("presence"); + presence["to"] = jid_to; + presence["from"] = muc_name + "@" + this->served_hostname + "/" + old_nick; + presence["type"] = "unavailable"; + XmlNode x("x"); + x["xmlns"] = MUC_USER_NS; + XmlNode item("item"); + item["nick"] = new_nick; + x.add_child(std::move(item)); + XmlNode status("status"); + status["code"] = "303"; + x.add_child(std::move(status)); + if (self) + { + XmlNode status2("status"); + status2["code"] = "110"; + x.add_child(std::move(status2)); + } + presence.add_child(std::move(x)); + this->send_stanza(presence); + + this->send_user_join(muc_name, new_nick, "", affiliation, role, jid_to, self); +} + +void XmppComponent::kick_user(const std::string& muc_name, + const std::string& target, + const std::string& txt, + const std::string& author, + const std::string& jid_to) +{ + Stanza presence("presence"); + presence["from"] = muc_name + "@" + this->served_hostname + "/" + target; + presence["to"] = jid_to; + presence["type"] = "unavailable"; + XmlNode x("x"); + x["xmlns"] = MUC_USER_NS; + XmlNode item("item"); + item["affiliation"] = "none"; + item["role"] = "none"; + XmlNode actor("actor"); + actor["nick"] = author; + actor["jid"] = author; // backward compatibility with old clients + item.add_child(std::move(actor)); + XmlNode reason("reason"); + reason.set_inner(txt); + item.add_child(std::move(reason)); + x.add_child(std::move(item)); + XmlNode status("status"); + status["code"] = "307"; + x.add_child(std::move(status)); + presence.add_child(std::move(x)); + this->send_stanza(presence); +} + +void XmppComponent::send_presence_error(const std::string& muc_name, + const std::string& nickname, + const std::string& jid_to, + const std::string& type, + const std::string& condition, + const std::string& error_code, + const std::string& /* text */) +{ + Stanza presence("presence"); + presence["from"] = muc_name + "@" + this->served_hostname + "/" + nickname; + presence["to"] = jid_to; + presence["type"] = "error"; + XmlNode x("x"); + x["xmlns"] = MUC_NS; + presence.add_child(std::move(x)); + XmlNode error("error"); + error["by"] = muc_name + "@" + this->served_hostname; + error["type"] = type; + if (!error_code.empty()) + error["code"] = error_code; + XmlNode subnode(condition); + subnode["xmlns"] = STANZA_NS; + error.add_child(std::move(subnode)); + presence.add_child(std::move(error)); + this->send_stanza(presence); +} + +void XmppComponent::send_affiliation_role_change(const std::string& muc_name, + const std::string& target, + const std::string& affiliation, + const std::string& role, + const std::string& jid_to) +{ + Stanza presence("presence"); + presence["from"] = muc_name + "@" + this->served_hostname + "/" + target; + presence["to"] = jid_to; + XmlNode x("x"); + x["xmlns"] = MUC_USER_NS; + XmlNode item("item"); + item["affiliation"] = affiliation; + item["role"] = role; + x.add_child(std::move(item)); + presence.add_child(std::move(x)); + this->send_stanza(presence); +} + +void XmppComponent::send_version(const std::string& id, const std::string& jid_to, const std::string& jid_from, + const std::string& version) +{ + Stanza iq("iq"); + iq["type"] = "result"; + iq["id"] = id; + iq["to"] = jid_to; + iq["from"] = jid_from; + XmlNode query("query"); + query["xmlns"] = VERSION_NS; + if (version.empty()) + { + XmlNode name("name"); + name.set_inner("biboumi"); + query.add_child(std::move(name)); + XmlNode version("version"); + version.set_inner(SOFTWARE_VERSION); + query.add_child(std::move(version)); + XmlNode os("os"); + os.set_inner(SYSTEM_NAME); + query.add_child(std::move(os)); + } + else + { + XmlNode name("name"); + name.set_inner(version); + query.add_child(std::move(name)); + } + iq.add_child(std::move(query)); + this->send_stanza(iq); +} + +void XmppComponent::send_adhoc_commands_list(const std::string& id, const std::string& requester_jid, + const std::string& from_jid, + const bool with_admin_only, const AdhocCommandsHandler& adhoc_handler) +{ + Stanza iq("iq"); + iq["type"] = "result"; + iq["id"] = id; + iq["to"] = requester_jid; + iq["from"] = from_jid; + XmlNode query("query"); + query["xmlns"] = DISCO_ITEMS_NS; + query["node"] = ADHOC_NS; + for (const auto& kv: adhoc_handler.get_commands()) + { + if (kv.second.is_admin_only() && !with_admin_only) + continue; + XmlNode item("item"); + item["jid"] = from_jid; + item["node"] = kv.first; + item["name"] = kv.second.name; + query.add_child(std::move(item)); + } + iq.add_child(std::move(query)); + this->send_stanza(iq); +} + +void XmppComponent::send_iq_version_request(const std::string& from, + const std::string& jid_to) +{ + Stanza iq("iq"); + iq["type"] = "get"; + iq["id"] = "version_"s + XmppComponent::next_id(); + iq["from"] = from + "@" + this->served_hostname; + iq["to"] = jid_to; + XmlNode query("query"); + query["xmlns"] = VERSION_NS; + iq.add_child(std::move(query)); + this->send_stanza(iq); +} + +void XmppComponent::send_iq_result_full_jid(const std::string& id, const std::string& to_jid, const std::string& from_full_jid) +{ + Stanza iq("iq"); + iq["from"] = from_full_jid; + iq["to"] = to_jid; + iq["id"] = id; + iq["type"] = "result"; + this->send_stanza(iq); +} + +void XmppComponent::send_iq_result(const std::string& id, const std::string& to_jid, const std::string& from_local_part) +{ + Stanza iq("iq"); + if (!from_local_part.empty()) + iq["from"] = from_local_part + "@" + this->served_hostname; + else + iq["from"] = this->served_hostname; + iq["to"] = to_jid; + iq["id"] = id; + iq["type"] = "result"; + this->send_stanza(iq); +} + +std::string XmppComponent::next_id() +{ + char uuid_str[37]; + uuid_t uuid; + uuid_generate(uuid); + uuid_unparse(uuid, uuid_str); + return uuid_str; +} diff --git a/louloulibs/xmpp/xmpp_component.hpp b/louloulibs/xmpp/xmpp_component.hpp new file mode 100644 index 0000000..5fc6d2e --- /dev/null +++ b/louloulibs/xmpp/xmpp_component.hpp @@ -0,0 +1,243 @@ +#pragma once + + +#include <xmpp/adhoc_commands_handler.hpp> +#include <network/tcp_socket_handler.hpp> +#include <xmpp/xmpp_parser.hpp> +#include <xmpp/body.hpp> + +#include <unordered_map> +#include <memory> +#include <string> +#include <map> + +#define STREAM_NS "http://etherx.jabber.org/streams" +#define COMPONENT_NS "jabber:component:accept" +#define MUC_NS "http://jabber.org/protocol/muc" +#define MUC_USER_NS MUC_NS"#user" +#define MUC_ADMIN_NS MUC_NS"#admin" +#define DISCO_NS "http://jabber.org/protocol/disco" +#define DISCO_ITEMS_NS DISCO_NS"#items" +#define DISCO_INFO_NS DISCO_NS"#info" +#define XHTMLIM_NS "http://jabber.org/protocol/xhtml-im" +#define STANZA_NS "urn:ietf:params:xml:ns:xmpp-stanzas" +#define STREAMS_NS "urn:ietf:params:xml:ns:xmpp-streams" +#define VERSION_NS "jabber:iq:version" +#define ADHOC_NS "http://jabber.org/protocol/commands" +#define PING_NS "urn:xmpp:ping" + +/** + * An XMPP component, communicating with an XMPP server using the protocole + * described in XEP-0114: Jabber Component Protocol + * + * TODO: implement XEP-0225: Component Connections + */ +class XmppComponent: public TCPSocketHandler +{ +public: + explicit XmppComponent(std::shared_ptr<Poller> poller, const std::string& hostname, const std::string& secret); + virtual ~XmppComponent() = default; + + XmppComponent(const XmppComponent&) = delete; + XmppComponent(XmppComponent&&) = delete; + XmppComponent& operator=(const XmppComponent&) = delete; + XmppComponent& operator=(XmppComponent&&) = delete; + + void on_connection_failed(const std::string& reason) override final; + void on_connected() override final; + void on_connection_close(const std::string& error) override final; + void parse_in_buffer(const size_t size) override final; + + /** + * Returns a unique id, to be used in the 'id' element of our iq stanzas. + */ + static std::string next_id(); + bool is_document_open() const; + /** + * Connect to the XMPP server. + */ + void start(); + /** + * Reset the component so we can use the component on a new XMPP stream + */ + void reset(); + /** + * Serialize the stanza and add it to the out_buf to be sent to the + * server. + */ + void send_stanza(const Stanza& stanza); + /** + * Handle the opening of the remote stream + */ + void on_remote_stream_open(const XmlNode& node); + /** + * Handle the closing of the remote stream + */ + void on_remote_stream_close(const XmlNode& node); + /** + * Handle received stanzas + */ + void on_stanza(const Stanza& stanza); + /** + * Send an error stanza. Message being the name of the element inside the + * stanza, and explanation being a short human-readable sentence + * describing the error. + */ + void send_stream_error(const std::string& message, const std::string& explanation); + /** + * Send error stanza, described in http://xmpp.org/rfcs/rfc6120.html#stanzas-error + */ + void send_stanza_error(const std::string& kind, const std::string& to, const std::string& from, + const std::string& id, const std::string& error_type, + const std::string& defined_condition, const std::string& text, + const bool fulljid=true); + /** + * Send the closing signal for our document (not closing the connection though). + */ + void close_document(); + /** + * Send a message from from@served_hostname, with the given body + * + * If fulljid is false, the provided 'from' doesn't contain the + * server-part of the JID and must be added. + */ + void send_message(const std::string& from, Xmpp::body&& body, + const std::string& to, const std::string& type, + const bool fulljid=false); + /** + * Send a join from a new participant + */ + void send_user_join(const std::string& from, + const std::string& nick, + const std::string& realjid, + const std::string& affiliation, + const std::string& role, + const std::string& to, + const bool self); + /** + * Send an error to indicate that the user tried to join an invalid room + */ + void send_invalid_room_error(const std::string& muc_jid, + const std::string& nick, + const std::string& to); + /** + * Send an error to indicate that the user tried to send a message to an + * invalid user. + */ + void send_invalid_user_error(const std::string& user_name, + const std::string& to); + /** + * Send the MUC topic to the user + */ + void send_topic(const std::string& from, Xmpp::body&& xmpp_topic, const std::string& to, const std::string& who); + /** + * Send a (non-private) message to the MUC + */ + void send_muc_message(const std::string& muc_name, const std::string& nick, Xmpp::body&& body, const std::string& jid_to); + /** + * Send an unavailable presence for this nick + */ + void send_muc_leave(const std::string& muc_name, std::string&& nick, Xmpp::body&& message, const std::string& jid_to, const bool self); + /** + * Indicate that a participant changed his nick + */ + void send_nick_change(const std::string& muc_name, + const std::string& old_nick, + const std::string& new_nick, + const std::string& affiliation, + const std::string& role, + const std::string& jid_to, + const bool self); + /** + * An user is kicked from a room + */ + void kick_user(const std::string& muc_name, + const std::string& target, + const std::string& reason, + const std::string& author, + const std::string& jid_to); + /** + * Send a generic presence error + */ + void send_presence_error(const std::string& muc_name, + const std::string& nickname, + const std::string& jid_to, + const std::string& type, + const std::string& condition, + const std::string& error_code, + const std::string& text); + /** + * Send a presence from the MUC indicating a change in the role and/or + * affiliation of a participant + */ + void send_affiliation_role_change(const std::string& muc_name, + const std::string& target, + const std::string& affiliation, + const std::string& role, + const std::string& jid_to); + /** + * Send a result IQ with the gateway disco informations. + */ + void send_self_disco_info(const std::string& id, const std::string& jid_to); + /** + * Send a result IQ with the given version, or the gateway version if the + * passed string is empty. + */ + void send_version(const std::string& id, const std::string& jid_to, const std::string& jid_from, + const std::string& version=""); + /** + * Send the list of all available ad-hoc commands to that JID. The list is + * different depending on what JID made the request. + */ + void send_adhoc_commands_list(const std::string& id, const std::string& requester_jid, const std::string& from_jid, + const bool with_admin_only, const AdhocCommandsHandler& adhoc_handler); + /** + * Send an iq version request + */ + void send_iq_version_request(const std::string& from, + const std::string& jid_to); + /** + * Send an empty iq of type result + */ + void send_iq_result(const std::string& id, const std::string& to_jid, const std::string& from); + void send_iq_result_full_jid(const std::string& id, const std::string& to_jid, + const std::string& from_full_jid); + + void handle_handshake(const Stanza& stanza); + void handle_error(const Stanza& stanza); + + virtual void after_handshake() {} + + /** + * Whether or not we ever succeeded our authentication to the XMPP server + */ + bool ever_auth; + /** + * Whether or not this is the first consecutive try on connecting to the + * XMPP server. We use this to delay the connection attempt for a few + * seconds, if it is not the first try. + */ + bool first_connection_try; + +private: + /** + * Return a buffer provided by the XML parser, to read data directly into + * it, and avoiding some unnecessary copy. + */ + void* get_receive_buffer(const size_t size) const override final; + XmppParser parser; + std::string stream_id; + std::string secret; + bool authenticated; + /** + * Whether or not OUR XMPP document is open + */ + bool doc_open; +protected: + std::string served_hostname; + + std::unordered_map<std::string, std::function<void(const Stanza&)>> stanza_handlers; + AdhocCommandsHandler adhoc_commands_handler; +}; + + diff --git a/louloulibs/xmpp/xmpp_parser.cpp b/louloulibs/xmpp/xmpp_parser.cpp new file mode 100644 index 0000000..0488be9 --- /dev/null +++ b/louloulibs/xmpp/xmpp_parser.cpp @@ -0,0 +1,172 @@ +#include <xmpp/xmpp_parser.hpp> +#include <xmpp/xmpp_stanza.hpp> + +#include <logger/logger.hpp> + +/** + * Expat handlers. Called by the Expat library, never by ourself. + * They just forward the call to the XmppParser corresponding methods. + */ + +static void start_element_handler(void* user_data, const XML_Char* name, const XML_Char** atts) +{ + static_cast<XmppParser*>(user_data)->start_element(name, atts); +} + +static void end_element_handler(void* user_data, const XML_Char* name) +{ + static_cast<XmppParser*>(user_data)->end_element(name); +} + +static void character_data_handler(void *user_data, const XML_Char *s, int len) +{ + static_cast<XmppParser*>(user_data)->char_data(s, len); +} + +/** + * XmppParser class + */ + +XmppParser::XmppParser(): + level(0), + current_node(nullptr), + root(nullptr) +{ + this->init_xml_parser(); +} + +void XmppParser::init_xml_parser() +{ + // Create the expat parser + this->parser = XML_ParserCreateNS("UTF-8", ':'); + XML_SetUserData(this->parser, static_cast<void*>(this)); + + // Install Expat handlers + XML_SetElementHandler(this->parser, &start_element_handler, &end_element_handler); + XML_SetCharacterDataHandler(this->parser, &character_data_handler); +} + +XmppParser::~XmppParser() +{ + XML_ParserFree(this->parser); +} + +int XmppParser::feed(const char* data, const int len, const bool is_final) +{ + int res = XML_Parse(this->parser, data, len, is_final); + if (res == XML_STATUS_ERROR && + (XML_GetErrorCode(this->parser) != XML_ERROR_FINISHED)) + log_error("Xml_Parse encountered an error: ", + XML_ErrorString(XML_GetErrorCode(this->parser))); + return res; +} + +int XmppParser::parse(const int len, const bool is_final) +{ + int res = XML_ParseBuffer(this->parser, len, is_final); + if (res == XML_STATUS_ERROR) + log_error("Xml_Parsebuffer encountered an error: ", + XML_ErrorString(XML_GetErrorCode(this->parser))); + return res; +} + +void XmppParser::reset() +{ + XML_ParserFree(this->parser); + this->init_xml_parser(); + this->current_node = nullptr; + this->root.reset(nullptr); + this->level = 0; +} + +void* XmppParser::get_buffer(const size_t size) const +{ + return XML_GetBuffer(this->parser, static_cast<int>(size)); +} + +void XmppParser::start_element(const XML_Char* name, const XML_Char** attribute) +{ + this->level++; + + auto new_node = std::make_unique<XmlNode>(name, this->current_node); + auto new_node_ptr = new_node.get(); + if (this->current_node) + this->current_node->add_child(std::move(new_node)); + else + this->root = std::move(new_node); + this->current_node = new_node_ptr; + for (size_t i = 0; attribute[i]; i += 2) + this->current_node->set_attribute(attribute[i], attribute[i+1]); + if (this->level == 1) + this->stream_open_event(*this->current_node); +} + +void XmppParser::end_element(const XML_Char*) +{ + this->level--; + if (this->level == 0) + { // End of the whole stream + this->stream_close_event(*this->current_node); + this->current_node = nullptr; + this->root.reset(); + } + else + { + auto parent = this->current_node->get_parent(); + if (this->level == 1) + { // End of a stanza + this->stanza_event(*this->current_node); + // Note: deleting all the children of our parent deletes ourself, + // so current_node is an invalid pointer after this line + parent->delete_all_children(); + } + this->current_node = parent; + } +} + +void XmppParser::char_data(const XML_Char* data, const size_t len) +{ + if (this->current_node->has_children()) + this->current_node->get_last_child()->add_to_tail({data, len}); + else + this->current_node->add_to_inner({data, len}); +} + +void XmppParser::stanza_event(const Stanza& stanza) const +{ + for (const auto& callback: this->stanza_callbacks) + { + try { + callback(stanza); + } catch (const std::exception& e) { + log_error("Unhandled exception: ", e.what()); + } + } +} + +void XmppParser::stream_open_event(const XmlNode& node) const +{ + for (const auto& callback: this->stream_open_callbacks) + callback(node); +} + +void XmppParser::stream_close_event(const XmlNode& node) const +{ + for (const auto& callback: this->stream_close_callbacks) + callback(node); +} + +void XmppParser::add_stanza_callback(std::function<void(const Stanza&)>&& callback) +{ + this->stanza_callbacks.emplace_back(std::move(callback)); +} + +void XmppParser::add_stream_open_callback(std::function<void(const XmlNode&)>&& callback) +{ + this->stream_open_callbacks.emplace_back(std::move(callback)); +} + +void XmppParser::add_stream_close_callback(std::function<void(const XmlNode&)>&& callback) +{ + this->stream_close_callbacks.emplace_back(std::move(callback)); +} diff --git a/louloulibs/xmpp/xmpp_parser.hpp b/louloulibs/xmpp/xmpp_parser.hpp new file mode 100644 index 0000000..9d67228 --- /dev/null +++ b/louloulibs/xmpp/xmpp_parser.hpp @@ -0,0 +1,133 @@ +#pragma once + + +#include <xmpp/xmpp_stanza.hpp> + +#include <functional> + +#include <expat.h> + +/** + * A SAX XML parser that builds XML nodes and spawns events when a complete + * stanza is received (an element of level 2), or when the document is + * opened/closed (an element of level 1) + * + * After a stanza_event has been spawned, we delete the whole stanza. This + * means that even with a very long document (in XMPP the document is + * potentially infinite), the memory is never exhausted as long as each + * stanza is reasonnably short. + * + * The element names generated by expat contain the namespace of the + * element, a colon (':') and then the actual name of the element. To get + * an element "x" with a namespace of "http://jabber.org/protocol/muc", you + * just look for an XmlNode named "http://jabber.org/protocol/muc:x" + * + * TODO: enforce the size-limit for the stanza (limit the number of childs + * it can contain). For example forbid the parser going further than level + * 20 (arbitrary number here), and each XML node to have more than 15 childs + * (arbitrary number again). + */ +class XmppParser +{ +public: + explicit XmppParser(); + ~XmppParser(); + XmppParser(const XmppParser&) = delete; + XmppParser& operator=(const XmppParser&) = delete; + XmppParser(XmppParser&&) = delete; + XmppParser& operator=(XmppParser&&) = delete; + +public: + /** + * Feed the parser with some XML data + */ + int feed(const char* data, const int len, const bool is_final); + /** + * Parse the data placed in the parser buffer + */ + int parse(const int size, const bool is_final); + /** + * Reset the parser, so it can be used from scratch afterward + */ + void reset(); + /** + * Get a buffer provided by the xml parser. + */ + void* get_buffer(const size_t size) const; + /** + * Add one callback for the various events that this parser can spawn. + */ + void add_stanza_callback(std::function<void(const Stanza&)>&& callback); + void add_stream_open_callback(std::function<void(const XmlNode&)>&& callback); + void add_stream_close_callback(std::function<void(const XmlNode&)>&& callback); + + /** + * Called when a new XML element has been opened. We instanciate a new + * XmlNode and set it as our current node. The parent of this new node is + * the previous "current" node. We have all the element's attributes in + * this event. + * + * We spawn a stream_event with this node if this is a level-1 element. + */ + void start_element(const XML_Char* name, const XML_Char** attribute); + /** + * Called when an XML element has been closed. We close the current_node, + * set our current_node as the parent of the current_node, and if that was + * a level-2 element we spawn a stanza_event with this node. + * + * And we then delete the stanza (and everything under it, its children, + * attribute, etc). + */ + void end_element(const XML_Char* name); + /** + * Some inner or tail data has been parsed + */ + void char_data(const XML_Char* data, const size_t len); + /** + * Calls all the stanza_callbacks one by one. + */ + void stanza_event(const Stanza& stanza) const; + /** + * Calls all the stream_open_callbacks one by one. Note: the passed node is not + * closed yet. + */ + void stream_open_event(const XmlNode& node) const; + /** + * Calls all the stream_close_callbacks one by one. + */ + void stream_close_event(const XmlNode& node) const; + +private: + /** + * Init the XML parser and install the callbacks + */ + void init_xml_parser(); + + /** + * Expat structure. + */ + XML_Parser parser; + /** + * The current depth in the XML document + */ + size_t level; + /** + * The deepest XML node opened but not yet closed (to which we are adding + * new children, inner or tail) + */ + XmlNode* current_node; + /** + * The root node has no parent, so we keep it here: the XmppParser object + * is its owner. + */ + std::unique_ptr<XmlNode> root; + /** + * A list of callbacks to be called on an *_event, receiving the + * concerned Stanza/XmlNode. + */ + std::vector<std::function<void(const Stanza&)>> stanza_callbacks; + std::vector<std::function<void(const XmlNode&)>> stream_open_callbacks; + std::vector<std::function<void(const XmlNode&)>> stream_close_callbacks; +}; + + diff --git a/louloulibs/xmpp/xmpp_stanza.cpp b/louloulibs/xmpp/xmpp_stanza.cpp new file mode 100644 index 0000000..ac6ce9b --- /dev/null +++ b/louloulibs/xmpp/xmpp_stanza.cpp @@ -0,0 +1,229 @@ +#include <xmpp/xmpp_stanza.hpp> + +#include <utils/encoding.hpp> +#include <utils/split.hpp> + +#include <stdexcept> +#include <iostream> +#include <sstream> + +#include <string.h> + +std::string xml_escape(const std::string& data) +{ + std::string res; + res.reserve(data.size()); + for (size_t pos = 0; pos != data.size(); ++pos) + { + switch(data[pos]) + { + case '&': + res += "&"; + break; + case '<': + res += "<"; + break; + case '>': + res += ">"; + break; + case '\"': + res += """; + break; + case '\'': + res += "'"; + break; + default: + res += data[pos]; + break; + } + } + return res; +} + +std::string sanitize(const std::string& data, const std::string& encoding) +{ + if (utils::is_valid_utf8(data.data())) + return xml_escape(utils::remove_invalid_xml_chars(data)); + else + return xml_escape(utils::remove_invalid_xml_chars(utils::convert_to_utf8(data, encoding.data()))); +} + +XmlNode::XmlNode(const std::string& name, XmlNode* parent): + parent(parent) +{ + // split the namespace and the name + auto n = name.rfind(":"); + if (n == std::string::npos) + this->name = name; + else + { + this->name = name.substr(n+1); + this->attributes["xmlns"] = name.substr(0, n); + } +} + +XmlNode::XmlNode(const std::string& name): + XmlNode(name, nullptr) +{ +} + +void XmlNode::delete_all_children() +{ + this->children.clear(); +} + +void XmlNode::set_attribute(const std::string& name, const std::string& value) +{ + this->attributes[name] = value; +} + +void XmlNode::set_tail(const std::string& data) +{ + this->tail = data; +} + +void XmlNode::add_to_tail(const std::string& data) +{ + this->tail += data; +} + +void XmlNode::set_inner(const std::string& data) +{ + this->inner = data; +} + +void XmlNode::add_to_inner(const std::string& data) +{ + this->inner += data; +} + +std::string XmlNode::get_inner() const +{ + return this->inner; +} + +std::string XmlNode::get_tail() const +{ + return this->tail; +} + +const XmlNode* XmlNode::get_child(const std::string& name, const std::string& xmlns) const +{ + for (const auto& child: this->children) + { + if (child->name == name && child->get_tag("xmlns") == xmlns) + return child.get(); + } + return nullptr; +} + +std::vector<const XmlNode*> XmlNode::get_children(const std::string& name, const std::string& xmlns) const +{ + std::vector<const XmlNode*> res; + for (const auto& child: this->children) + { + if (child->name == name && child->get_tag("xmlns") == xmlns) + res.push_back(child.get()); + } + return res; +} + +XmlNode* XmlNode::add_child(std::unique_ptr<XmlNode> child) +{ + child->parent = this; + auto ret = child.get(); + this->children.push_back(std::move(child)); + return ret; +} + +XmlNode* XmlNode::add_child(XmlNode&& child) +{ + auto new_node = std::make_unique<XmlNode>(std::move(child)); + return this->add_child(std::move(new_node)); +} + +XmlNode* XmlNode::add_child(const XmlNode& child) +{ + auto new_node = std::make_unique<XmlNode>(child); + return this->add_child(std::move(new_node)); +} + +XmlNode* XmlNode::get_last_child() const +{ + return this->children.back().get(); +} + +XmlNode* XmlNode::get_parent() const +{ + return this->parent; +} + +void XmlNode::set_name(const std::string& name) +{ + this->name = name; +} + +void XmlNode::set_name(std::string&& name) +{ + this->name = std::move(name); +} + +const std::string XmlNode::get_name() const +{ + return this->name; +} + +std::string XmlNode::to_string() const +{ + std::ostringstream res; + res << "<" << this->name; + for (const auto& it: this->attributes) + res << " " << it.first << "='" << sanitize(it.second) + "'"; + if (!this->has_children() && this->inner.empty()) + res << "/>"; + else + { + res << ">" + sanitize(this->inner); + for (const auto& child: this->children) + res << child->to_string(); + res << "</" << this->get_name() << ">"; + } + res << sanitize(this->tail); + return res.str(); +} + +bool XmlNode::has_children() const +{ + return !this->children.empty(); +} + +const std::string& XmlNode::get_tag(const std::string& name) const +{ + try + { + const auto& value = this->attributes.at(name); + return value; + } + catch (const std::out_of_range& e) + { + static const std::string def{}; + return def; + } +} + +bool XmlNode::del_tag(const std::string& name) +{ + if (this->attributes.erase(name) != 0) + return true; + return false; +} + +std::string& XmlNode::operator[](const std::string& name) +{ + return this->attributes[name]; +} + +std::ostream& operator<<(std::ostream& os, const XmlNode& node) +{ + return os << node.to_string(); +} diff --git a/louloulibs/xmpp/xmpp_stanza.hpp b/louloulibs/xmpp/xmpp_stanza.hpp new file mode 100644 index 0000000..4ca758e --- /dev/null +++ b/louloulibs/xmpp/xmpp_stanza.hpp @@ -0,0 +1,146 @@ +#pragma once + + +#include <map> +#include <string> +#include <vector> +#include <memory> + +std::string xml_escape(const std::string& data); +std::string xml_unescape(const std::string& data); +std::string sanitize(const std::string& data, const std::string& encoding = "ISO-8859-1"); + +/** + * Represent an XML node. It has + * - A parent XML node (in the case of the first-level nodes, the parent is + nullptr) + * - zero, one or more children XML nodes + * - A name + * - A map of attributes + * - inner data (text inside the node) + * - tail data (text just after the node) + */ +class XmlNode +{ +public: + explicit XmlNode(const std::string& name, XmlNode* parent); + explicit XmlNode(const std::string& name); + /** + * The copy constructor does not copy the parent attribute. The children + * nodes are all copied recursively. + */ + XmlNode(const XmlNode& node): + name(node.name), + parent(nullptr), + attributes(node.attributes), + children{}, + inner(node.inner), + tail(node.tail) + { + for (const auto& child: node.children) + this->add_child(std::make_unique<XmlNode>(*child)); + } + + XmlNode(XmlNode&& node) = default; + XmlNode& operator=(const XmlNode&) = delete; + XmlNode& operator=(XmlNode&&) = delete; + + ~XmlNode() = default; + + void delete_all_children(); + void set_attribute(const std::string& name, const std::string& value); + /** + * Set the content of the tail, that is the text just after this node + */ + void set_tail(const std::string& data); + /** + * Append the given data to the content of the tail. This exists because + * the expat library may provide the complete text of an element in more + * than one call + */ + void add_to_tail(const std::string& data); + /** + * Set the content of the inner, that is the text inside this node. + */ + void set_inner(const std::string& data); + /** + * Append the given data to the content of the inner. For the reason + * described in add_to_tail comment. + */ + void add_to_inner(const std::string& data); + /** + * Get the content of inner + */ + std::string get_inner() const; + /** + * Get the content of the tail + */ + std::string get_tail() const; + /** + * Get a pointer to the first child element with that name and that xml namespace + */ + const XmlNode* get_child(const std::string& name, const std::string& xmlns) const; + /** + * Get a vector of all the children that have that name and that xml namespace. + */ + std::vector<const XmlNode*> get_children(const std::string& name, const std::string& xmlns) const; + /** + * Add a node child to this node. Assign this node to the child’s parent. + * Returns a pointer to the newly added child. + */ + XmlNode* add_child(std::unique_ptr<XmlNode> child); + XmlNode* add_child(XmlNode&& child); + XmlNode* add_child(const XmlNode& child); + /** + * Returns the last of the children. If the node doesn't have any child, + * the behaviour is undefined. The user should make sure this is the case + * by calling has_children() for example. + */ + XmlNode* get_last_child() const; + XmlNode* get_parent() const; + void set_name(const std::string& name); + void set_name(std::string&& name); + const std::string get_name() const; + /** + * Serialize the stanza into a string + */ + std::string to_string() const; + /** + * Whether or not this node has at least one child (if not, this is a leaf + * node) + */ + bool has_children() const; + /** + * Gets the value for the given attribute, returns an empty string if the + * node as no such attribute. + */ + const std::string& get_tag(const std::string& name) const; + /** + * Remove the attribute of the node. Does nothing if that attribute is not + * present. Returns true if the tag was removed, false if it was absent. + */ + bool del_tag(const std::string& name); + /** + * Use this to set an attribute's value, like node["id"] = "12"; + */ + std::string& operator[](const std::string& name); + +private: + std::string name; + XmlNode* parent; + std::map<std::string, std::string> attributes; + std::vector<std::unique_ptr<XmlNode>> children; + std::string inner; + std::string tail; +}; + +std::ostream& operator<<(std::ostream& os, const XmlNode& node); + +/** + * An XMPP stanza is just an XML node of level 2 in the XMPP document (the + * level 1 ones are the <stream::stream/>, and the ones above 2 are just the + * content of the stanzas) + */ +using Stanza = XmlNode; + + diff --git a/packaging/biboumi.spec.cmake b/packaging/biboumi.spec.cmake new file mode 100644 index 0000000..c94da8b --- /dev/null +++ b/packaging/biboumi.spec.cmake @@ -0,0 +1,81 @@ +Name: biboumi +Version: ${RPM_VERSION} +Release: 1%{?dist} +Summary: Lightweight XMPP to IRC gateway + +License: zlib +URL: http://biboumi.louiz.org +Source0: http://git.louiz.org/biboumi/snapshot/biboumi-%{version}.tar.xz + +BuildRequires: libidn-devel +BuildRequires: expat-devel +BuildRequires: libuuid-devel +BuildRequires: systemd-devel +BuildRequires: cmake +BuildRequires: systemd +BuildRequires: pandoc + +%global _hardened_build 1 + +%global biboumi_confdir %{_sysconfdir}/%{name} + + +%description +An XMPP gateway that connects to IRC servers and translates between the two +protocols. It can be used to access IRC channels using any XMPP client as if +these channels were XMPP MUCs. + + +%prep +%setup -q + + +%build +cmake . -DCMAKE_CXX_FLAGS="%{optflags}" \ + -DCMAKE_BUILD_TYPE=release \ + -DCMAKE_INSTALL_PREFIX=/usr \ + -DPOLLER=EPOLL \ + -DWITHOUT_BOTAN=1 \ + -DWITH_SYSTEMD=1 \ + -DWITH_LIBIDN=1 + +make %{?_smp_mflags} + + +%install +make install DESTDIR=%{buildroot} + + +%check +make check %{?_smp_mflags} + + +%files +%{_bindir}/%{name} +%{_mandir}/man1/%{name}.1* +%doc README.rst COPYING doc/biboumi.1.rst +%{_unitdir}/%{name}.service +%config(noreplace) %{biboumi_confdir}/biboumi.cfg + + +%changelog +* Thu Aug 4 2016 Le Coz Florent <louiz@louiz.org> - 3.0-1 +- Update to 3.0 sources + +* Wed Jan 13 2016 Le Coz Florent <louiz@louiz.org> - 2.0-2 +- Do not install the systemd unit and configuration files, because + “make install” does it itself now + +* Fri May 29 2015 Le Coz Florent <louiz@louiz.org> - 2.0-1 +- Update to 2.0 sources + +* Thu Nov 13 2014 Le Coz Florent <louiz@louiz.org> - 1.1-2 +- Use the -DWITH(OUT) cmake flags for all optional dependencies +- Build with the correct optflags +- Use hardened_build + +* Mon Aug 18 2014 Le Coz Florent <louiz@louiz.org> - 1.1-1 +- Update to 1.1 release + +* Wed Jun 25 2014 Le Coz Florent <louiz@louiz.org> - 1.0-1 +- Spec file written from scratch diff --git a/src/bridge/bridge.cpp b/src/bridge/bridge.cpp new file mode 100644 index 0000000..17d3ec6 --- /dev/null +++ b/src/bridge/bridge.cpp @@ -0,0 +1,907 @@ +#include <bridge/bridge.hpp> +#include <bridge/list_element.hpp> +#include <xmpp/biboumi_component.hpp> +#include <network/poller.hpp> +#include <utils/empty_if_fixed_server.hpp> +#include <utils/encoding.hpp> +#include <utils/tolower.hpp> +#include <logger/logger.hpp> +#include <utils/revstr.hpp> +#include <utils/split.hpp> +#include <xmpp/jid.hpp> +#include <database/database.hpp> + +using namespace std::string_literals; + +static const char* action_prefix = "\01ACTION "; + + +static std::string in_encoding_for(const Bridge& bridge, const Iid& iid) +{ +#ifdef USE_DATABASE + const auto jid = bridge.get_bare_jid(); + auto options = Database::get_irc_channel_options_with_server_default(jid, iid.get_server(), iid.get_local()); + return options.encodingIn.value(); +#else + return {"ISO-8859-1"}; +#endif +} + +Bridge::Bridge(const std::string& user_jid, BiboumiComponent& xmpp, std::shared_ptr<Poller> poller): + user_jid(user_jid), + xmpp(xmpp), + poller(poller) +{ +} + +/** + * Return the role and affiliation, corresponding to the given irc mode + */ +static std::tuple<std::string, std::string> get_role_affiliation_from_irc_mode(const char mode) +{ + if (mode == 'a' || mode == 'q'){ + return std::make_tuple("moderator", "owner");} + else if (mode == 'o') + return std::make_tuple("moderator", "admin"); + else if (mode == 'h') + return std::make_tuple("moderator", "member"); + else if (mode == 'v') + return std::make_tuple("participant", "member"); + else + return std::make_tuple("participant", "none"); +} + +void Bridge::shutdown(const std::string& exit_message) +{ + for (auto it = this->irc_clients.begin(); it != this->irc_clients.end(); ++it) + { + it->second->send_quit_command(exit_message); + it->second->leave_dummy_channel(exit_message); + } +} + +void Bridge::clean() +{ + auto it = this->irc_clients.begin(); + while (it != this->irc_clients.end()) + { + IrcClient* client = it->second.get(); + if (!client->is_connected() && !client->is_connecting() && + !client->get_resolver().is_resolving()) + it = this->irc_clients.erase(it); + else + ++it; + } +} + +const std::string& Bridge::get_jid() const +{ + return this->user_jid; +} + +std::string Bridge::get_bare_jid() const +{ + Jid jid(this->user_jid); + return jid.local + "@" + jid.domain; +} + +Xmpp::body Bridge::make_xmpp_body(const std::string& str, const std::string& encoding) +{ + std::string res; + if (utils::is_valid_utf8(str.c_str())) + res = str; + else + res = utils::convert_to_utf8(str, encoding.data()); + return irc_format_to_xhtmlim(res); +} + +IrcClient* Bridge::make_irc_client(const std::string& hostname, const std::string& nickname) +{ + try + { + return this->irc_clients.at(hostname).get(); + } + catch (const std::out_of_range& exception) + { + auto username = nickname; + auto realname = nickname; + Jid jid(this->user_jid); + if (Config::get("realname_from_jid", "false") == "true") + { + username = jid.local; + realname = this->get_bare_jid(); + } + this->irc_clients.emplace(hostname, + std::make_shared<IrcClient>(this->poller, hostname, + nickname, username, + realname, jid.domain, + *this)); + std::shared_ptr<IrcClient> irc = this->irc_clients.at(hostname); + return irc.get(); + } +} + +IrcClient* Bridge::get_irc_client(const std::string& hostname) +{ + try + { + return this->irc_clients.at(hostname).get(); + } + catch (const std::out_of_range& exception) + { + throw IRCNotConnected(hostname); + } +} + +IrcClient* Bridge::find_irc_client(const std::string& hostname) +{ + try + { + return this->irc_clients.at(hostname).get(); + } + catch (const std::out_of_range& exception) + { + return nullptr; + } +} + +bool Bridge::join_irc_channel(const Iid& iid, const std::string& nickname, const std::string& password, + const std::string& resource) +{ + const auto hostname = iid.get_server(); + IrcClient* irc = this->make_irc_client(hostname, nickname); + this->add_resource_to_server(hostname, resource); + auto res_in_chan = this->is_resource_in_chan(ChannelKey{iid.get_local(), hostname}, resource); + if (!res_in_chan) + this->add_resource_to_chan(ChannelKey{iid.get_local(), hostname}, resource); + if (iid.get_local().empty()) + { // Join the dummy channel + if (irc->is_welcomed()) + { + if (irc->get_dummy_channel().joined) + return false; + // Immediately simulate a message coming from the IRC server saying that we + // joined the channel + const IrcMessage join_message(irc->get_nick(), "JOIN", {""}); + irc->on_channel_join(join_message); + const IrcMessage end_join_message(std::string(iid.get_server()), "366", + {irc->get_nick(), + "", "End of NAMES list"}); + irc->on_channel_completely_joined(end_join_message); + } + else + { + irc->get_dummy_channel().joining = true; + irc->start(); + } + return true; + } + if (irc->is_channel_joined(iid.get_local()) == false) + { + irc->send_join_command(iid.get_local(), password); + return true; + } else if (!res_in_chan) { + this->generate_channel_join_for_resource(iid, resource); + } + return false; +} + +void Bridge::send_channel_message(const Iid& iid, const std::string& body) +{ + if (iid.get_server().empty()) + { + for (const auto& resource: this->resources_in_chan[iid.to_tuple()]) + this->xmpp.send_stanza_error("message", this->user_jid + "/" + resource, std::to_string(iid), "", + "cancel", "remote-server-not-found", + std::to_string(iid) + " is not a valid channel name. " + "A correct room jid is of the form: #<chan>%<server>", + false); + return; + } + IrcClient* irc = this->get_irc_client(iid.get_server()); + + // Because an IRC message cannot contain \n, we need to convert each line + // of text into a separate IRC message. For conveniance, we also cut the + // message into submessages on the XMPP side, this way the user of the + // gateway sees what was actually sent over IRC. For example if an user + // sends “hello\n/me waves”, two messages will be generated: “hello” and + // “/me waves”. Note that the “command” handling (messages starting with + // /me, /mode, etc) is done for each message generated this way. In the + // above example, the /me will be interpreted. + std::vector<std::string> lines = utils::split(body, '\n', true); + if (lines.empty()) + return ; + for (const std::string& line: lines) + { + if (line.substr(0, 5) == "/mode") + { + std::vector<std::string> args = utils::split(line.substr(5), ' ', false); + irc->send_mode_command(iid.get_local(), args); + continue; // We do not want to send that back to the + // XMPP user, that’s not a textual message. + } + else if (line.substr(0, 4) == "/me ") + irc->send_channel_message(iid.get_local(), action_prefix + line.substr(4) + "\01"); + else + irc->send_channel_message(iid.get_local(), line); + for (const auto& resource: this->resources_in_chan[iid.to_tuple()]) + this->xmpp.send_muc_message(std::to_string(iid), irc->get_own_nick(), + this->make_xmpp_body(line), this->user_jid + "/" + resource); + } +} + +void Bridge::forward_affiliation_role_change(const Iid& iid, const std::string& nick, + const std::string& affiliation, + const std::string& role) +{ + IrcClient* irc = this->get_irc_client(iid.get_server()); + IrcChannel* chan = irc->get_channel(iid.get_local()); + if (!chan || !chan->joined) + return; + IrcUser* user = chan->find_user(nick); + if (!user) + return; + // For each affiliation or role, we have a “maximal” mode that we want to + // set. We must remove any superior mode at the same time. For example if + // the user already has +o mode, and we set its affiliation to member, we + // remove the +o mode, and add +v. For each “superior” mode (for example, + // for +v, the superior modes are 'h', 'a', 'o' and 'q') we check if that + // user has it, and if yes we remove that mode + + std::size_t nb = 1; // the number of times the nick must be + // repeated in the argument list + std::string modes; // The string of modes to + // add/remove. For example "+v-aoh" + std::vector<char> modes_to_remove; // List of modes to check for removal + if (affiliation == "none") + { + modes = ""; + nb = 0; + modes_to_remove = {'v', 'h', 'o', 'a', 'q'}; + } + else if (affiliation == "member") + { + modes = "+v"; + modes_to_remove = {'h', 'o', 'a', 'q'}; + } + else if (role == "moderator") + { + modes = "+h"; + modes_to_remove = {'o', 'a', 'q'}; + } + else if (affiliation == "admin") + { + modes = "+o"; + modes_to_remove = {'a', 'q'}; + } + else if (affiliation == "owner") + { + modes = "+a"; + modes_to_remove = {'q'}; + } + else + return; + for (const char mode: modes_to_remove) + if (user->modes.find(mode) != user->modes.end()) + { + modes += "-"s + mode; + nb++; + } + if (modes.empty()) + return; + std::vector<std::string> args(nb, nick); + args.insert(args.begin(), modes); + irc->send_mode_command(iid.get_local(), args); +} + +void Bridge::send_private_message(const Iid& iid, const std::string& body, const std::string& type) +{ + if (iid.get_local().empty() || iid.get_server().empty()) + { + this->xmpp.send_stanza_error("message", this->user_jid, std::to_string(iid), "", + "cancel", "remote-server-not-found", + std::to_string(iid) + " is not a valid channel name. " + "A correct room jid is of the form: #<chan>%<server>", + false); + return; + } + IrcClient* irc = this->get_irc_client(iid.get_server()); + std::vector<std::string> lines = utils::split(body, '\n', true); + if (lines.empty()) + return ; + for (const std::string& line: lines) + { + if (line.substr(0, 4) == "/me ") + irc->send_private_message(iid.get_local(), action_prefix + line.substr(4) + "\01", type); + else + irc->send_private_message(iid.get_local(), line, type); + } +} + +void Bridge::send_raw_message(const std::string& hostname, const std::string& body) +{ + IrcClient* irc = this->get_irc_client(hostname); + irc->send_raw(body); +} + +void Bridge::leave_irc_channel(Iid&& iid, std::string&& status_message, const std::string& resource) +{ + IrcClient* irc = this->get_irc_client(iid.get_server()); + const auto key = iid.to_tuple(); + if (!this->is_resource_in_chan(key, resource)) + return ; + + const auto resources = this->number_of_resources_in_chan(key); + if (resources == 1) + { + irc->send_part_command(iid.get_local(), status_message); + // Since there are no resources left in that channel, we don't + // want to receive private messages using this room's JID + this->remove_all_preferred_from_jid_of_room(iid.get_local()); + } + else + { + IrcChannel* chan = irc->get_channel(iid.get_local()); + if (chan) + { + auto nick = chan->get_self()->nick; + this->remove_resource_from_chan(key, resource); + this->send_muc_leave(std::move(iid), std::move(nick), + "Biboumi note: "s + std::to_string(resources - 1) + " resources are still in this channel.", + true, resource); + if (this->number_of_channels_the_resource_is_in(iid.get_server(), resource) == 0) + this->remove_resource_from_server(iid.get_server(), resource); + } + } +} + +void Bridge::send_irc_nick_change(const Iid& iid, const std::string& new_nick) +{ + IrcClient* irc = this->get_irc_client(iid.get_server()); + irc->send_nick_command(new_nick); +} + +void Bridge::send_irc_channel_list_request(const Iid& iid, const std::string& iq_id, + const std::string& to_jid) +{ + IrcClient* irc = this->get_irc_client(iid.get_server()); + + irc->send_list_command(); + + std::vector<ListElement> list; + + irc_responder_callback_t cb = [this, iid, iq_id, to_jid, list=std::move(list)](const std::string& irc_hostname, + const IrcMessage& message) mutable -> bool + { + if (irc_hostname != iid.get_server()) + return false; + if (message.command == "263" || message.command == "RPL_TRYAGAIN" || + message.command == "ERR_TOOMANYMATCHES" || message.command == "ERR_NOSUCHSERVER") + { + std::string text; + if (message.arguments.size() >= 2) + text = message.arguments[1]; + this->xmpp.send_stanza_error("iq", to_jid, std::to_string(iid), iq_id, + "wait", "service-unavailable", text, false); + return true; + } + else if (message.command == "322" || message.command == "RPL_LIST") + { // Add element to list + if (message.arguments.size() == 4) + list.emplace_back(message.arguments[1], message.arguments[2], + message.arguments[3]); + return false; + } + else if (message.command == "323" || message.command == "RPL_LISTEND") + { // Send the iq response with the content of the list + this->xmpp.send_iq_room_list_result(iq_id, to_jid, std::to_string(iid), list); + return true; + } + return false; + }; + this->add_waiting_irc(std::move(cb)); +} + +void Bridge::send_irc_kick(const Iid& iid, const std::string& target, const std::string& reason, + const std::string& iq_id, const std::string& to_jid) +{ + IrcClient* irc = this->get_irc_client(iid.get_server()); + + irc->send_kick_command(iid.get_local(), target, reason); + irc_responder_callback_t cb = [this, target, iq_id, to_jid, iid](const std::string& irc_hostname, + const IrcMessage& message) -> bool + { + if (irc_hostname != iid.get_server()) + return false; + if (message.command == "KICK" && message.arguments.size() >= 2) + { + const std::string target_later = message.arguments[1]; + const std::string chan_name_later = utils::tolower(message.arguments[0]); + if (target_later != target || chan_name_later != iid.get_local()) + return false; + this->xmpp.send_iq_result(iq_id, to_jid, std::to_string(iid)); + } + else if (message.command == "401" && message.arguments.size() >= 2) + { + const std::string target_later = message.arguments[1]; + if (target_later != target) + return false; + std::string error_message = "No such nick"; + if (message.arguments.size() >= 3) + error_message = message.arguments[2]; + this->xmpp.send_stanza_error("iq", to_jid, std::to_string(iid), iq_id, "cancel", "item-not-found", + error_message, false); + } + else if (message.command == "482" && message.arguments.size() >= 2) + { + const std::string chan_name_later = utils::tolower(message.arguments[1]); + if (chan_name_later != iid.get_local()) + return false; + std::string error_message = "You're not channel operator"; + if (message.arguments.size() >= 3) + error_message = message.arguments[2]; + this->xmpp.send_stanza_error("iq", to_jid, std::to_string(iid), iq_id, "cancel", "not-allowed", + error_message, false); + } + return true; + }; + this->add_waiting_irc(std::move(cb)); +} + +void Bridge::set_channel_topic(const Iid& iid, const std::string& subject) +{ + IrcClient* irc = this->get_irc_client(iid.get_server()); + irc->send_topic_command(iid.get_local(), subject); +} + +void Bridge::send_xmpp_version_to_irc(const Iid& iid, const std::string& name, const std::string& version, const std::string& os) +{ + std::string result(name + " " + version + " " + os); + + this->send_private_message(iid, "\01VERSION "s + result + "\01", "NOTICE"); +} + +void Bridge::send_irc_ping_result(const Iid& iid, const std::string& id) +{ + this->send_private_message(iid, "\01PING "s + utils::revstr(id) + "\01", "NOTICE"); +} + +void Bridge::send_irc_user_ping_request(const std::string& irc_hostname, const std::string& nick, + const std::string& iq_id, const std::string& to_jid, + const std::string& from_jid) +{ + Iid iid(nick + "!" + irc_hostname); + this->send_private_message(iid, "\01PING " + iq_id + "\01"); + + irc_responder_callback_t cb = [this, nick=utils::tolower(nick), iq_id, to_jid, irc_hostname, from_jid](const std::string& hostname, const IrcMessage& message) -> bool + { + if (irc_hostname != hostname || message.arguments.size() < 2) + return false; + IrcUser user(message.prefix); + const std::string body = message.arguments[1]; + if (message.command == "NOTICE" && utils::tolower(user.nick) == nick + && body.substr(0, 6) == "\01PING ") + { + const std::string id = body.substr(6, body.size() - 7); + if (id != iq_id) + return false; + this->xmpp.send_iq_result_full_jid(iq_id, to_jid, from_jid); + return true; + } + if (message.command == "401" && message.arguments[1] == nick) + { + std::string error_message = "No such nick"; + if (message.arguments.size() >= 3) + error_message = message.arguments[2]; + this->xmpp.send_stanza_error("iq", to_jid, from_jid, iq_id, "cancel", "service-unavailable", + error_message, true); + return true; + } + + return false; + }; + this->add_waiting_irc(std::move(cb)); +} + +void Bridge::send_irc_participant_ping_request(const Iid& iid, const std::string& nick, + const std::string& iq_id, const std::string& to_jid, + const std::string& from_jid) +{ + IrcClient* irc = this->get_irc_client(iid.get_server()); + IrcChannel* chan = irc->get_channel(iid.get_local()); + if (!chan->joined) + { + this->xmpp.send_stanza_error("iq", to_jid, from_jid, iq_id, "cancel", "not-allowed", + "", true); + return; + } + if (chan->get_self()->nick != nick && !chan->find_user(nick)) + { + this->xmpp.send_stanza_error("iq", to_jid, from_jid, iq_id, "cancel", "item-not-found", + "Recipient not in room", true); + return; + } + + // The user is in the room, send it a direct PING + this->send_irc_user_ping_request(iid.get_server(), nick, iq_id, to_jid, from_jid); +} + +void Bridge::on_gateway_ping(const std::string& irc_hostname, const std::string& iq_id, const std::string& to_jid, + const std::string& from_jid) +{ + Jid jid(from_jid); + if (irc_hostname.empty() || this->find_irc_client(irc_hostname)) + this->xmpp.send_iq_result(iq_id, to_jid, jid.local); + else + this->xmpp.send_stanza_error("iq", to_jid, from_jid, iq_id, "cancel", "service-unavailable", + "", true); +} + +void Bridge::send_irc_version_request(const std::string& irc_hostname, const std::string& target, + const std::string& iq_id, const std::string& to_jid, + const std::string& from_jid) +{ + Iid iid(target + "!" + irc_hostname); + this->send_private_message(iid, "\01VERSION\01"); + // TODO, add a timer to remove that waiting iq if the server does not + // respond with a matching command before n seconds + irc_responder_callback_t cb = [this, target, iq_id, to_jid, irc_hostname, from_jid](const std::string& hostname, const IrcMessage& message) -> bool + { + if (irc_hostname != hostname) + return false; + IrcUser user(message.prefix); + if (message.command == "NOTICE" && user.nick == target && + message.arguments.size() >= 2 && message.arguments[1].substr(0, 9) == "\01VERSION ") + { + // remove the "\01VERSION " and the "\01" parts from the string + const std::string version = message.arguments[1].substr(9, message.arguments[1].size() - 10); + this->xmpp.send_version(iq_id, to_jid, from_jid, version); + return true; + } + if (message.command == "401" && message.arguments.size() >= 2 + && message.arguments[1] == target) + { + std::string error_message = "No such nick"; + if (message.arguments.size() >= 3) + error_message = message.arguments[2]; + this->xmpp.send_stanza_error("iq", to_jid, from_jid, iq_id, "cancel", "item-not-found", + error_message, true); + return true; + } + return false; + }; + this->add_waiting_irc(std::move(cb)); +} + +void Bridge::send_message(const Iid& iid, const std::string& nick, const std::string& body, const bool muc) +{ + const auto encoding = in_encoding_for(*this, iid); + if (muc) + { + for (const auto& resource: this->resources_in_chan[iid.to_tuple()]) + { + this->xmpp.send_muc_message(std::to_string(iid), nick, + this->make_xmpp_body(body, encoding), this->user_jid + "/" + resource); + } + } + else + { + std::string target = std::to_string(iid); + const auto it = this->preferred_user_from.find(iid.get_local()); + if (it != this->preferred_user_from.end()) + { + const auto chan_name = Iid(Jid(it->second).local).get_local(); + for (const auto& resource: this->resources_in_chan[ChannelKey{chan_name, iid.get_server()}]) + this->xmpp.send_message(it->second, this->make_xmpp_body(body, encoding), + this->user_jid + "/" + resource, "chat", true); + } + else + { + for (const auto& resource: this->resources_in_server[iid.get_server()]) + this->xmpp.send_message(std::to_string(iid), this->make_xmpp_body(body, encoding), + this->user_jid + "/" + resource, "chat", false); + } + } +} + +void Bridge::send_presence_error(const Iid& iid, const std::string& nick, + const std::string& type, const std::string& condition, + const std::string& error_code, const std::string& text) +{ + this->xmpp.send_presence_error(std::to_string(iid), nick, this->user_jid, type, condition, error_code, text); +} + +void Bridge::send_muc_leave(Iid&& iid, std::string&& nick, const std::string& message, const bool self, const std::string& resource) +{ + if (!resource.empty()) + this->xmpp.send_muc_leave(std::to_string(iid), std::move(nick), this->make_xmpp_body(message), this->user_jid + "/" + resource, + self); + else + for (const auto& res: this->resources_in_chan[iid.to_tuple()]) + this->xmpp.send_muc_leave(std::to_string(iid), std::move(nick), this->make_xmpp_body(message), this->user_jid + "/" + res, + self); + IrcClient* irc = this->find_irc_client(iid.get_server()); + if (irc && irc->number_of_joined_channels() == 0) + irc->send_quit_command(""); +} + +void Bridge::send_nick_change(Iid&& iid, + const std::string& old_nick, + const std::string& new_nick, + const char user_mode, + const bool self) +{ + std::string affiliation; + std::string role; + std::tie(role, affiliation) = get_role_affiliation_from_irc_mode(user_mode); + + for (const auto& resource: this->resources_in_chan[iid.to_tuple()]) + this->xmpp.send_nick_change(std::to_string(iid), + old_nick, new_nick, affiliation, role, this->user_jid + "/" + resource, self); +} + +void Bridge::send_xmpp_message(const std::string& from, const std::string& author, const std::string& msg) +{ + std::string body; + if (!author.empty()) + { + IrcUser user(author); + body = "\u000303"s + user.nick + (user.host.empty()? + "\u0003: ": + (" (\u000310" + user.host + "\u000303)\u0003: ")) + msg; + } + else + body = msg; + + const auto encoding = in_encoding_for(*this, {from}); + for (const auto& resource: this->resources_in_server[from]) + { + this->xmpp.send_message(from, this->make_xmpp_body(body, encoding), this->user_jid + "/" + resource, "chat"); + } +} + +void Bridge::send_user_join(const std::string& hostname, const std::string& chan_name, + const IrcUser* user, const char user_mode, const bool self) +{ + for (const auto& resource: this->resources_in_chan[ChannelKey{chan_name, hostname}]) + this->send_user_join(hostname, chan_name, user, user_mode, self, resource); +} + +void Bridge::send_user_join(const std::string& hostname, const std::string& chan_name, + const IrcUser* user, const char user_mode, + const bool self, const std::string& resource) +{ + std::string affiliation; + std::string role; + std::tie(role, affiliation) = get_role_affiliation_from_irc_mode(user_mode); + + std::string encoded_chan_name(chan_name); + xep0106::encode(encoded_chan_name); + + this->xmpp.send_user_join(encoded_chan_name + utils::empty_if_fixed_server("%" + hostname), user->nick, user->host, + affiliation, role, this->user_jid + "/" + resource, self); +} + +void Bridge::send_topic(const std::string& hostname, const std::string& chan_name, const std::string& topic, const std::string& who) +{ + for (const auto& resource: this->resources_in_chan[ChannelKey{chan_name, hostname}]) + { + this->send_topic(hostname, chan_name, topic, who, resource); + } +} + +void Bridge::send_topic(const std::string& hostname, const std::string& chan_name, + const std::string& topic, const std::string& who, + const std::string& resource) +{ + std::string encoded_chan_name(chan_name); + xep0106::encode(encoded_chan_name); + const auto encoding = in_encoding_for(*this, {encoded_chan_name + '%' + hostname}); + this->xmpp.send_topic(encoded_chan_name + utils::empty_if_fixed_server( + "%" + hostname), this->make_xmpp_body(topic, encoding), this->user_jid + "/" + resource, who); + +} + +std::string Bridge::get_own_nick(const Iid& iid) +{ + IrcClient* irc = this->find_irc_client(iid.get_server()); + if (irc) + return irc->get_own_nick(); + return ""; +} + +size_t Bridge::active_clients() const +{ + return this->irc_clients.size(); +} + +void Bridge::kick_muc_user(Iid&& iid, const std::string& target, const std::string& reason, const std::string& author) +{ + for (const auto& resource: this->resources_in_chan[iid.to_tuple()]) + this->xmpp.kick_user(std::to_string(iid), target, reason, author, this->user_jid + "/" + resource); +} + +void Bridge::send_nickname_conflict_error(const Iid& iid, const std::string& nickname) +{ + for (const auto& resource: this->resources_in_chan[iid.to_tuple()]) + this->xmpp.send_presence_error(std::to_string(iid), nickname, this->user_jid + "/" + resource, "cancel", "conflict", "409", ""); +} + +void Bridge::send_affiliation_role_change(const Iid& iid, const std::string& target, const char mode) +{ + std::string role; + std::string affiliation; + + std::tie(role, affiliation) = get_role_affiliation_from_irc_mode(mode); + for (const auto& resource: this->resources_in_chan[iid.to_tuple()]) + this->xmpp.send_affiliation_role_change(std::to_string(iid), target, affiliation, role, this->user_jid + "/" + resource); +} + +void Bridge::send_iq_version_request(const std::string& nick, const std::string& hostname) +{ + const auto resources = this->resources_in_server[hostname]; + if (resources.begin() != resources.end()) + this->xmpp.send_iq_version_request(utils::tolower(nick) + "!" + utils::empty_if_fixed_server(hostname), this->user_jid + "/" + *resources.begin()); +} + +void Bridge::send_xmpp_ping_request(const std::string& nick, const std::string& hostname, + const std::string& id) +{ + // Use revstr because the forwarded ping to target XMPP user must not be + // the same that the request iq, but we also need to get it back easily + // (revstr again) + // Forward to the first resource (arbitrary, based on the “order” of the std::set) only + const auto resources = this->resources_in_server[hostname]; + if (resources.begin() != resources.end()) + this->xmpp.send_ping_request(utils::tolower(nick) + "!" + utils::empty_if_fixed_server(hostname), this->user_jid + "/" + *resources.begin(), utils::revstr(id)); +} + +void Bridge::set_preferred_from_jid(const std::string& nick, const std::string& full_jid) +{ + auto it = this->preferred_user_from.find(nick); + if (it == this->preferred_user_from.end()) + this->preferred_user_from.emplace(nick, full_jid); + else + this->preferred_user_from[nick] = full_jid; +} + +void Bridge::remove_preferred_from_jid(const std::string& nick) +{ + auto it = this->preferred_user_from.find(nick); + if (it != this->preferred_user_from.end()) + this->preferred_user_from.erase(it); +} + +void Bridge::remove_all_preferred_from_jid_of_room(const std::string& channel_name) +{ + for (auto it = this->preferred_user_from.begin(); it != this->preferred_user_from.end();) + { + Iid iid(Jid(it->second).local); + if (iid.get_local() == channel_name) + it = this->preferred_user_from.erase(it); + else + ++it; + } +} + +void Bridge::add_waiting_irc(irc_responder_callback_t&& callback) +{ + this->waiting_irc.emplace_back(std::move(callback)); +} + +void Bridge::trigger_on_irc_message(const std::string& irc_hostname, const IrcMessage& message) +{ + auto it = this->waiting_irc.begin(); + while (it != this->waiting_irc.end()) + { + if ((*it)(irc_hostname, message) == true) + it = this->waiting_irc.erase(it); + else + ++it; + } +} + +std::unordered_map<std::string, std::shared_ptr<IrcClient>>& Bridge::get_irc_clients() +{ + return this->irc_clients; +} + +void Bridge::add_resource_to_chan(const Bridge::ChannelKey& channel, const std::string& resource) +{ + auto it = this->resources_in_chan.find(channel); + if (it == this->resources_in_chan.end()) + this->resources_in_chan[channel] = {resource}; + else + it->second.insert(resource); +} + +void Bridge::remove_resource_from_chan(const Bridge::ChannelKey& channel, const std::string& resource) +{ + auto it = this->resources_in_chan.find(channel); + if (it != this->resources_in_chan.end()) + { + it->second.erase(resource); + if (it->second.empty()) + this->resources_in_chan.erase(it); + } +} + +bool Bridge::is_resource_in_chan(const Bridge::ChannelKey& channel, const std::string& resource) const +{ + auto it = this->resources_in_chan.find(channel); + if (it != this->resources_in_chan.end()) + if (it->second.count(resource) == 1) + return true; + return false; +} + +void Bridge::add_resource_to_server(const Bridge::IrcHostname& irc_hostname, const std::string& resource) +{ + auto it = this->resources_in_server.find(irc_hostname); + if (it == this->resources_in_server.end()) + this->resources_in_server[irc_hostname] = {resource}; + else + it->second.insert(resource); +} + +void Bridge::remove_resource_from_server(const Bridge::IrcHostname& irc_hostname, const std::string& resource) +{ + auto it = this->resources_in_server.find(irc_hostname); + if (it != this->resources_in_server.end()) + { + it->second.erase(resource); + if (it->second.empty()) + this->resources_in_server.erase(it); + } +} + +bool Bridge::is_resource_in_server(const Bridge::IrcHostname& irc_hostname, const std::string& resource) const +{ + auto it = this->resources_in_server.find(irc_hostname); + if (it != this->resources_in_server.end()) + if (it->second.count(resource) == 1) + return true; + return false; +} + +std::size_t Bridge::number_of_resources_in_chan(const Bridge::ChannelKey& channel_key) const +{ + auto it = this->resources_in_chan.find(channel_key); + if (it == this->resources_in_chan.end()) + return 0; + return it->second.size(); +} + +std::size_t Bridge::number_of_channels_the_resource_is_in(const std::string& irc_hostname, const std::string& resource) const +{ + std::size_t res = 0; + for (auto pair: this->resources_in_chan) + { + if (std::get<0>(pair.first) == irc_hostname && pair.second.count(resource) != 0) + res++; + } + return res; +} + +void Bridge::generate_channel_join_for_resource(const Iid& iid, const std::string& resource) +{ + IrcClient* irc = this->get_irc_client(iid.get_server()); + IrcChannel* channel = irc->get_channel(iid.get_local()); + const auto self = channel->get_self(); + + // Send the occupant list + for (const auto& user: channel->get_users()) + { + if (user->nick != self->nick) + { + log_debug(user->nick); + this->send_user_join(iid.get_server(), iid.get_encoded_local(), + user.get(), user->get_most_significant_mode(irc->get_sorted_user_modes()), + false, resource); + } + } + this->send_user_join(iid.get_server(), iid.get_encoded_local(), + self, self->get_most_significant_mode(irc->get_sorted_user_modes()), + true, resource); + this->send_topic(iid.get_server(), iid.get_encoded_local(), channel->topic, channel->topic_author, resource); +} diff --git a/src/bridge/bridge.hpp b/src/bridge/bridge.hpp new file mode 100644 index 0000000..69b7bd5 --- /dev/null +++ b/src/bridge/bridge.hpp @@ -0,0 +1,293 @@ +#pragma once + + +#include <irc/irc_message.hpp> +#include <irc/irc_client.hpp> +#include <bridge/colors.hpp> +#include <irc/irc_user.hpp> +#include <irc/iid.hpp> + +#include <unordered_map> +#include <functional> +#include <exception> +#include <string> +#include <memory> + +class BiboumiComponent; +class Poller; + +/** + * A callback called for each IrcMessage we receive. If the message triggers + * a response, it must send ore or more iq and return true (in that case it + * is removed from the list), otherwise it must do nothing and just return + * false. + */ +using irc_responder_callback_t = std::function<bool(const std::string& irc_hostname, const IrcMessage& message)>; + +/** + * One bridge is spawned for each XMPP user that uses the component. The + * bridge spawns IrcClients when needed (when the user wants to join a + * channel on a new server) and does the translation between the two + * protocols. + */ +class Bridge +{ +public: + explicit Bridge(const std::string& user_jid, BiboumiComponent& xmpp, std::shared_ptr<Poller> poller); + ~Bridge() = default; + + Bridge(const Bridge&) = delete; + Bridge(Bridge&& other) = delete; + Bridge& operator=(const Bridge&) = delete; + Bridge& operator=(Bridge&&) = delete; + /** + * QUIT all connected IRC servers. + */ + void shutdown(const std::string& exit_message); + /** + * Remove all inactive IrcClients + */ + void clean(); + /** + * Return the jid of the XMPP user using this bridge + */ + const std::string& get_jid() const; + std::string get_bare_jid() const; + + static Xmpp::body make_xmpp_body(const std::string& str, const std::string& encoding = "ISO-8859-1"); + /*** + ** + ** From XMPP to IRC. + ** + **/ + + /** + * Try to join an irc_channel, does nothing and return true if the channel + * was already joined. + */ + bool join_irc_channel(const Iid& iid, const std::string& nickname, const std::string& password, const std::string& resource); + + void send_channel_message(const Iid& iid, const std::string& body); + void send_private_message(const Iid& iid, const std::string& body, const std::string& type="PRIVMSG"); + void send_raw_message(const std::string& hostname, const std::string& body); + void leave_irc_channel(Iid&& iid, std::string&& status_message, const std::string& resource); + void send_irc_nick_change(const Iid& iid, const std::string& new_nick); + void send_irc_kick(const Iid& iid, const std::string& target, const std::string& reason, + const std::string& iq_id, const std::string& to_jid); + void set_channel_topic(const Iid& iid, const std::string& subject); + void send_xmpp_version_to_irc(const Iid& iid, const std::string& name, const std::string& version, + const std::string& os); + void send_irc_ping_result(const Iid& iid, const std::string& id); + void send_irc_version_request(const std::string& irc_hostname, const std::string& target, + const std::string& iq_id, const std::string& to_jid, + const std::string& from_jid); + void send_irc_channel_list_request(const Iid& iid, const std::string& iq_id, + const std::string& to_jid); + void forward_affiliation_role_change(const Iid& iid, const std::string& nick, + const std::string& affiliation, const std::string& role); + /** + * Directly send a CTCP PING request to the IRC user + */ + void send_irc_user_ping_request(const std::string& irc_hostname, const std::string& nick, + const std::string& iq_id, const std::string& to_jid, + const std::string& from_jid); + /** + * First check if the participant is in the room, before sending a direct + * CTCP PING request to the IRC user + */ + void send_irc_participant_ping_request(const Iid& iid, const std::string& nick, + const std::string& iq_id, const std::string& to_jid, + const std::string& from_jid); + /** + * Directly send back a result if it's a gateway ping or if we are + * connected to the given IRC server, an error otherwise. + */ + void on_gateway_ping(const std::string& irc_hostname, const std::string& iq_id, const std::string& to_jid, + const std::string& from_jid); + + /*** + ** + ** From IRC to XMPP. + ** + **/ + + /** + * Send a message corresponding to a server NOTICE, the from attribute + * should be juste the server hostname. + */ + void send_xmpp_message(const std::string& from, const std::string& author, const std::string& msg); + /** + * Send the presence of a new user in the MUC. + */ + void send_user_join(const std::string& hostname, const std::string& chan_name, + const IrcUser* user, const char user_mode, + const bool self, const std::string& resource); + void send_user_join(const std::string& hostname, const std::string& chan_name, + const IrcUser* user, const char user_mode, + const bool self); + + /** + * Send the topic of the MUC to the user + */ + void send_topic(const std::string& hostname, const std::string& chan_name, const std::string& topic, const std::string& who); + void send_topic(const std::string& hostname, const std::string& chan_name, const std::string& topic, const std::string& who, const std::string& resource); + /** + * Send a MUC message from some participant + */ + void send_message(const Iid& iid, const std::string& nick, const std::string& body, const bool muc); + /** + * Send a presence of type error, from a room. + */ + void send_presence_error(const Iid& iid, const std::string& nick, const std::string& type, const std::string& condition, const std::string& error_code, const std::string& text); + /** + * Send an unavailable presence from this participant + */ + void send_muc_leave(Iid&& iid, std::string&& nick, const std::string& message, const bool self, const std::string& resource=""); + /** + * Send presences to indicate that an user old_nick (ourself if self == + * true) changed his nick to new_nick. The user_mode is needed because + * the xmpp presence needs ton contain the role and affiliation of the + * user. + */ + void send_nick_change(Iid&& iid, + const std::string& old_nick, + const std::string& new_nick, + const char user_mode, + const bool self); + void kick_muc_user(Iid&& iid, const std::string& target, const std::string& reason, const std::string& author); + void send_nickname_conflict_error(const Iid& iid, const std::string& nickname); + /** + * Send a role/affiliation change, matching the change of mode for that user + */ + void send_affiliation_role_change(const Iid& iid, const std::string& target, const char mode); + /** + * Send an iq version request coming from nick!hostname@ + */ + void send_iq_version_request(const std::string& nick, const std::string& hostname); + /** + * Send an iq ping request coming from nick!hostname@ + */ + void send_xmpp_ping_request(const std::string& nick, const std::string& hostname, + const std::string& id); + /** + * Misc + */ + std::string get_own_nick(const Iid& iid); + /** + * Get the number of server to which this bridge is connected or connecting. + */ + size_t active_clients() const; + /** + * Add (or replace the existing) <nick, jid> into the preferred_user_from map + */ + void set_preferred_from_jid(const std::string& nick, const std::string& full_jid); + /** + * Remove the preferred jid for the given IRC nick + */ + void remove_preferred_from_jid(const std::string& nick); + /** + * Given a channel_name, remove all preferred from_jid that come + * from this chan. + */ + void remove_all_preferred_from_jid_of_room(const std::string& channel_name); + /** + * Add a callback to the waiting list of irc callbacks. + */ + void add_waiting_irc(irc_responder_callback_t&& callback); + /** + * Iter over all the waiting_iq, call the iq_responder_filter_t for each, + * whenever one of them returns true: call the corresponding + * iq_responder_callback_t and remove the callback from the list. + */ + void trigger_on_irc_message(const std::string& irc_hostname, const IrcMessage& message); + std::unordered_map<std::string, std::shared_ptr<IrcClient>>& get_irc_clients(); + +private: + /** + * Returns the client for the given hostname, create one (and use the + * username in this case) if none is found, and connect that newly-created + * client immediately. + */ + IrcClient* make_irc_client(const std::string& hostname, const std::string& nickname); + /** + * This version does not create the IrcClient if it does not exist, throws + * a IRCServerNotConnected error in that case. + */ + IrcClient* get_irc_client(const std::string& hostname); + /** + * Idem, but returns nullptr if the server does not exist. + */ + IrcClient* find_irc_client(const std::string& hostname); + /** + * The bare JID of the user associated with this bridge. Messages from/to this + * JID are only managed by this bridge. + */ + const std::string user_jid; + /** + * One IrcClient for each IRC server we need to be connected to. + * The pointer is shared by the bridge and the poller. + */ + std::unordered_map<std::string, std::shared_ptr<IrcClient>> irc_clients; + /** + * To communicate back with the XMPP component + */ + BiboumiComponent& xmpp; + /** + * Poller, to give it the IrcClients that we spawn, to make it manage + * their sockets. + */ + std::shared_ptr<Poller> poller; + /** + * A map of <nick, full_jid>. For example if this map contains <"toto", + * "#somechan%server@biboumi/ToTo">, whenever a private message is + * received from the user "toto", instead of forwarding it to XMPP with + * from='toto!server@biboumi', we use instead + * from='#somechan%server@biboumi/ToTo' + */ + std::unordered_map<std::string, std::string> preferred_user_from; + /** + * A list of callbacks that are waiting for some IrcMessage to trigger a + * response. We add callbacks in this list whenever we received an IQ + * request and we need a response from IRC to be able to provide the + * response iq. + */ + std::vector<irc_responder_callback_t> waiting_irc; + + /** + * Resources to IRC channel/server mapping: + */ + using Resource = std::string; + using ChannelName = std::string; + using IrcHostname = std::string; + using ChannelKey = std::tuple<ChannelName, IrcHostname>; + std::map<ChannelKey, std::set<Resource>> resources_in_chan; + std::map<IrcHostname, std::set<Resource>> resources_in_server; + /** + * Manage which resource is in which channel + */ + void add_resource_to_chan(const ChannelKey& channel_key, const std::string& resource); + void remove_resource_from_chan(const ChannelKey& channel_key, const std::string& resource); + bool is_resource_in_chan(const ChannelKey& channel_key, const std::string& resource) const; + std::size_t number_of_resources_in_chan(const ChannelKey& channel_key) const; + + void add_resource_to_server(const IrcHostname& irc_hostname, const std::string& resource); + void remove_resource_from_server(const IrcHostname& irc_hostname, const std::string& resource); + bool is_resource_in_server(const IrcHostname& irc_hostname, const std::string& resource) const; + size_t number_of_channels_the_resource_is_in(const std::string& irc_hostname, const std::string& resource) const; + + /** + * Generate all the stanzas to be sent to this resource, simulating a join on this channel. + * This means sending the whole user list, the topic, etc + * TODO: send message history + */ + void generate_channel_join_for_resource(const Iid& iid, const std::string& resource); +}; + +struct IRCNotConnected: public std::exception +{ + IRCNotConnected(const std::string& hostname): + hostname(hostname) {} + const std::string hostname; +}; + + diff --git a/src/bridge/colors.cpp b/src/bridge/colors.cpp new file mode 100644 index 0000000..66f51ee --- /dev/null +++ b/src/bridge/colors.cpp @@ -0,0 +1,170 @@ +#include <bridge/colors.hpp> +#include <xmpp/xmpp_stanza.hpp> + +#include <algorithm> +#include <iostream> + +#include <string.h> + +using namespace std::string_literals; + +static const char IRC_NUM_COLORS = 16; + +static const char* irc_colors_to_css[IRC_NUM_COLORS] = { + "white", + "black", + "blue", + "green", + "indianred", + "red", + "magenta", + "brown", + "yellow", + "lightgreen", + "cyan", + "lightcyan", + "lightblue", + "lightmagenta", + "gray", + "white", +}; + +#define XHTML_NS "http://www.w3.org/1999/xhtml" + +struct styles_t +{ + bool strong; + bool underline; + bool italic; + int fg; + int bg; +}; + +/** We keep the currently-applied CSS styles in a structure. Each time a tag + * is found, update this style list, then close the current span XML element + * (if it is open), then reopen it with all the new styles in it. This is + * done this way because IRC formatting does not map well with XML + * (hierarchical tags), it’s a lot easier and cleaner to remove all styles + * and reapply them for each tag, instead of trying to keep a consistent + * hierarchy of span, strong, em etc tags. The generated XML is one-level + * deep only. +*/ +Xmpp::body irc_format_to_xhtmlim(const std::string& s) +{ + if (s.find_first_of(irc_format_char) == std::string::npos) + // there is no special formatting at all + return std::make_tuple(s, nullptr); + + std::string cleaned; + + styles_t styles = {false, false, false, -1, -1}; + + std::unique_ptr<XmlNode> result = std::make_unique<XmlNode>("body"); + (*result)["xmlns"] = XHTML_NS; + + std::unique_ptr<XmlNode> current_node_up; + XmlNode* current_node = result.get(); + + std::string::size_type pos_start = 0; + std::string::size_type pos_end; + + while ((pos_end = s.find_first_of(irc_format_char, pos_start)) != std::string::npos) + { + const std::string txt = s.substr(pos_start, pos_end-pos_start); + cleaned += txt; + if (current_node->has_children()) + current_node->get_last_child()->add_to_tail(txt); + else + current_node->add_to_inner(txt); + + if (s[pos_end] == IRC_FORMAT_BOLD_CHAR) + styles.strong = !styles.strong; + else if (s[pos_end] == IRC_FORMAT_NEWLINE_CHAR) + { + current_node->add_child(std::make_unique<XmlNode>("br")); + cleaned += '\n'; + } + else if (s[pos_end] == IRC_FORMAT_UNDERLINE_CHAR) + styles.underline = !styles.underline; + else if (s[pos_end] == IRC_FORMAT_ITALIC_CHAR) + styles.italic = !styles.italic; + else if (s[pos_end] == IRC_FORMAT_RESET_CHAR) + styles = {false, false, false, -1, -1}; + else if (s[pos_end] == IRC_FORMAT_REVERSE_CHAR) + { } // TODO + else if (s[pos_end] == IRC_FORMAT_REVERSE2_CHAR) + { } // TODO + else if (s[pos_end] == IRC_FORMAT_FIXED_CHAR) + { } // TODO + else if (s[pos_end] == IRC_FORMAT_COLOR_CHAR) + { + size_t pos = pos_end + 1; + styles.fg = -1; + styles.bg = -1; + // get the first number following the format char + if (pos < s.size() && s[pos] >= '0' && s[pos] <= '9') + { // first digit + styles.fg = s[pos++] - '0'; + if (pos < s.size() && s[pos] >= '0' && s[pos] <= '9') + // second digit + styles.fg = styles.fg * 10 + s[pos++] - '0'; + } + if (pos < s.size() && s[pos] == ',') + { // get bg color after the comma + pos++; + if (pos < s.size() && s[pos] >= '0' && s[pos] <= '9') + { // first digit + styles.bg = s[pos++] - '0'; + if (pos < s.size() && s[pos] >= '0' && s[pos] <= '9') + // second digit + styles.bg = styles.bg * 10 + s[pos++] - '0'; + } + } + pos_end = pos - 1; + } + + // close opened span, if any + if (current_node != result.get()) + { + result->add_child(std::move(current_node_up)); + current_node = result.get(); + } + // Take all currently-applied style and create a new span with it + std::string styles_str; + if (styles.strong) + styles_str += "font-weight:bold;"; + if (styles.underline) + styles_str += "text-decoration:underline;"; + if (styles.italic) + styles_str += "font-style:italic;"; + if (styles.fg != -1) + styles_str += "color:"s + + irc_colors_to_css[styles.fg % IRC_NUM_COLORS] + ";"; + if (styles.bg != -1) + styles_str += "background-color:"s + + irc_colors_to_css[styles.bg % IRC_NUM_COLORS] + ";"; + if (!styles_str.empty()) + { + current_node_up = std::make_unique<XmlNode>("span"); + current_node = current_node_up.get(); + (*current_node)["style"] = styles_str; + } + + pos_start = pos_end + 1; + } + + // If some text remains, without any format char, just append that text at + // the end of the current node + const std::string txt = s.substr(pos_start, pos_end-pos_start); + cleaned += txt; + if (current_node->has_children()) + current_node->get_last_child()->add_to_tail(txt); + else + current_node->add_to_inner(txt); + + if (current_node != result.get()) + result->add_child(std::move(current_node_up)); + + Xmpp::body body_res = std::make_tuple(cleaned, std::move(result)); + return body_res; +} diff --git a/src/bridge/colors.hpp b/src/bridge/colors.hpp new file mode 100644 index 0000000..e2c8a87 --- /dev/null +++ b/src/bridge/colors.hpp @@ -0,0 +1,56 @@ +#pragma once + + +/** + * A module handling the conversion between IRC colors and XHTML-IM, and + * vice versa. + */ + +#include <string> +#include <memory> +#include <tuple> + +class XmlNode; + +namespace Xmpp +{ +// Contains: +// - an XMPP-valid UTF-8 body +// - an XML node representing the XHTML-IM body, or null + using body = std::tuple<const std::string, std::unique_ptr<XmlNode>>; +} + +#define IRC_FORMAT_BOLD_CHAR '\x02' // done +#define IRC_FORMAT_COLOR_CHAR '\x03' // done +#define IRC_FORMAT_RESET_CHAR '\x0F' // done +#define IRC_FORMAT_FIXED_CHAR '\x11' // ?? +#define IRC_FORMAT_REVERSE_CHAR '\x12' // maybe one day +#define IRC_FORMAT_REVERSE2_CHAR '\x16' // wat +#define IRC_FORMAT_ITALIC_CHAR '\x1D' // done +#define IRC_FORMAT_UNDERLINE_CHAR '\x1F' // done +#define IRC_FORMAT_NEWLINE_CHAR '\n' // done + +static const char irc_format_char[] = { + IRC_FORMAT_BOLD_CHAR, + IRC_FORMAT_COLOR_CHAR, + IRC_FORMAT_RESET_CHAR, + IRC_FORMAT_FIXED_CHAR, + IRC_FORMAT_REVERSE_CHAR, + IRC_FORMAT_REVERSE2_CHAR, + IRC_FORMAT_ITALIC_CHAR, + IRC_FORMAT_UNDERLINE_CHAR, + IRC_FORMAT_NEWLINE_CHAR, + '\x00' +}; + +/** + * Convert the passed string into an XML tree representing the XHTML version + * of the message, converting the IRC colors symbols into xhtml-im + * formatting. + * + * Returns the body cleaned from any IRC formatting (but without any xhtml), + * and the body as XHTML-IM + */ +Xmpp::body irc_format_to_xhtmlim(const std::string& str); + + diff --git a/src/bridge/list_element.hpp b/src/bridge/list_element.hpp new file mode 100644 index 0000000..1eff2ee --- /dev/null +++ b/src/bridge/list_element.hpp @@ -0,0 +1,19 @@ +#pragma once + + +#include <string> + +struct ListElement +{ + ListElement(const std::string& channel, const std::string& nb_users, + const std::string& topic): + channel(channel), + nb_users(nb_users), + topic(topic){} + + std::string channel; + std::string nb_users; + std::string topic; +}; + + diff --git a/src/database/database.cpp b/src/database/database.cpp new file mode 100644 index 0000000..61e1b47 --- /dev/null +++ b/src/database/database.cpp @@ -0,0 +1,87 @@ +#include "biboumi.h" +#ifdef USE_DATABASE + +#include <database/database.hpp> +#include <logger/logger.hpp> +#include <string> + +using namespace std::string_literals; + +std::unique_ptr<db::BibouDB> Database::db; + +void Database::open(const std::string& filename, const std::string& db_type) +{ + try + { + auto new_db = std::make_unique<db::BibouDB>(db_type, + "database="s + filename); + if (new_db->needsUpgrade()) + new_db->upgrade(); + Database::db.reset(new_db.release()); + } catch (const litesql::DatabaseError& e) { + log_error("Failed to open database ", filename, ". ", e.what()); + throw; + } +} + +void Database::set_verbose(const bool val) +{ + Database::db->verbose = val; +} + +db::IrcServerOptions Database::get_irc_server_options(const std::string& owner, + const std::string& server) +{ + try { + auto options = litesql::select<db::IrcServerOptions>(*Database::db, + db::IrcServerOptions::Owner == owner && + db::IrcServerOptions::Server == server).one(); + return options; + } catch (const litesql::NotFound& e) { + db::IrcServerOptions options(*Database::db); + options.owner = owner; + options.server = server; + // options.update(); + return options; + } +} + +db::IrcChannelOptions Database::get_irc_channel_options(const std::string& owner, + const std::string& server, + const std::string& channel) +{ + try { + auto options = litesql::select<db::IrcChannelOptions>(*Database::db, + db::IrcChannelOptions::Owner == owner && + db::IrcChannelOptions::Server == server && + db::IrcChannelOptions::Channel == channel).one(); + return options; + } catch (const litesql::NotFound& e) { + db::IrcChannelOptions options(*Database::db); + options.owner = owner; + options.server = server; + options.channel = channel; + return options; + } +} + +db::IrcChannelOptions Database::get_irc_channel_options_with_server_default(const std::string& owner, + const std::string& server, + const std::string& channel) +{ + auto coptions = Database::get_irc_channel_options(owner, server, channel); + auto soptions = Database::get_irc_server_options(owner, server); + if (coptions.encodingIn.value().empty()) + coptions.encodingIn = soptions.encodingIn; + if (coptions.encodingOut.value().empty()) + coptions.encodingOut = soptions.encodingOut; + + return coptions; +} + +void Database::close() +{ + Database::db.reset(nullptr); +} + +#endif diff --git a/src/database/database.hpp b/src/database/database.hpp new file mode 100644 index 0000000..7173bcd --- /dev/null +++ b/src/database/database.hpp @@ -0,0 +1,53 @@ +#pragma once + + +#include <biboumi.h> +#ifdef USE_DATABASE + +#include "biboudb.hpp" + +#include <memory> + +#include <litesql.hpp> + +class Database +{ +public: + Database() = default; + ~Database() = default; + + Database(const Database&) = delete; + Database(Database&&) = delete; + Database& operator=(const Database&) = delete; + Database& operator=(Database&&) = delete; + + static void set_verbose(const bool val); + + template<typename PersistentType> + static size_t count() + { + return litesql::select<PersistentType>(*Database::db).count(); + } + /** + * Return the object from the db. Create it beforehand (with all default + * values) if it is not already present. + */ + static db::IrcServerOptions get_irc_server_options(const std::string& owner, + const std::string& server); + static db::IrcChannelOptions get_irc_channel_options(const std::string& owner, + const std::string& server, + const std::string& channel); + static db::IrcChannelOptions get_irc_channel_options_with_server_default(const std::string& owner, + const std::string& server, + const std::string& channel); + + static void close(); + static void open(const std::string& filename, const std::string& db_type="sqlite3"); + + +private: + static std::unique_ptr<db::BibouDB> db; +}; +#endif /* USE_DATABASE */ + + diff --git a/src/irc/iid.cpp b/src/irc/iid.cpp new file mode 100644 index 0000000..0e2841e --- /dev/null +++ b/src/irc/iid.cpp @@ -0,0 +1,118 @@ +#include <utils/tolower.hpp> +#include <config/config.hpp> + +#include <irc/iid.hpp> + +#include <utils/encoding.hpp> + +Iid::Iid(const std::string& iid): + is_channel(false), + is_user(false) +{ + const std::string fixed_irc_server = Config::get("fixed_irc_server", ""); + if (fixed_irc_server.empty()) + this->init(iid); + else + this->init_with_fixed_server(iid, fixed_irc_server); +} + + +void Iid::init(const std::string& iid) +{ + const std::string::size_type sep = iid.find_first_of("%!"); + if (sep != std::string::npos) + { + if (iid[sep] == '%') + this->is_channel = true; + else + this->is_user = true; + this->set_local(iid.substr(0, sep)); + this->set_server(iid.substr(sep + 1)); + } + else + this->set_server(iid); +} + +void Iid::init_with_fixed_server(const std::string& iid, const std::string& hostname) +{ + this->set_server(hostname); + + const std::string::size_type sep = iid.find("!"); + + // Without any separator, we consider that it's a channel + if (sep == std::string::npos) + { + this->is_channel = true; + this->set_local(iid); + } + else // A separator can be present to differenciate a channel from a user, + // but the part behind it (the hostname) is ignored + { + this->set_local(iid.substr(0, sep)); + this->is_user = true; + } +} + +Iid::Iid(): + is_channel(false), + is_user(false) +{ +} + +void Iid::set_local(const std::string& loc) +{ + std::string local(utils::tolower(loc)); + xep0106::decode(local); + this->local = local; +} + +void Iid::set_server(const std::string& serv) +{ + this->server = utils::tolower(serv); +} + +const std::string& Iid::get_local() const +{ + return this->local; +} + +const std::string Iid::get_encoded_local() const +{ + std::string local(this->local); + xep0106::encode(local); + return local; +} + +const std::string& Iid::get_server() const +{ + return this->server; +} + +std::string Iid::get_sep() const +{ + if (this->is_channel) + return "%"; + else if (this->is_user) + return "!"; + return ""; +} + +namespace std { + const std::string to_string(const Iid& iid) + { + if (Config::get("fixed_irc_server", "").empty()) + return iid.get_encoded_local() + iid.get_sep() + iid.get_server(); + else + { + if (iid.get_sep() == "!") + return iid.get_encoded_local() + iid.get_sep(); + else + return iid.get_encoded_local(); + } + } +} + +std::tuple<std::string, std::string> Iid::to_tuple() const +{ + return std::make_tuple(this->get_local(), this->get_server()); +} diff --git a/src/irc/iid.hpp b/src/irc/iid.hpp new file mode 100644 index 0000000..3b11470 --- /dev/null +++ b/src/irc/iid.hpp @@ -0,0 +1,79 @@ +#pragma once + + +#include <string> + +/** + * A name representing an IRC channel on an IRC server, or an IRC user on an + * IRC server, or just an IRC server. + * + * The separator for an user is '!', for a channel it's '%'. If no separator + * is present, it's just an irc server. + * It’s possible to have an empty-string server, but it makes no sense in + * the biboumi context. + * + * #test%irc.example.org has : + * - local: "#test" (the # is part of the name, it could very well be absent, or & (for example) instead) + * - server: "irc.example.org" + * - is_channel: true + * - is_user: false + * + * %irc.example.org: + * - local: "" + * - server: "irc.example.org" + * - is_channel: true + * - is_user: false + * Note: this is the special empty-string channel, used internal in biboumi + * but has no meaning on IRC. + * + * foo!irc.example.org + * - local: "foo" + * - server: "irc.example.org" + * - is_channel: false + * - is_user: true + * Note: the empty-string user (!irc.example.org) has no special meaning in biboumi + * + * irc.example.org: + * - local: "" + * - server: "irc.example.org" + * - is_channel: false + * - is_user: false + */ +class Iid +{ +public: + Iid(const std::string& iid); + Iid(); + Iid(const Iid&) = default; + + Iid(Iid&&) = delete; + Iid& operator=(const Iid&) = delete; + Iid& operator=(Iid&&) = delete; + + void set_local(const std::string& loc); + void set_server(const std::string& serv); + const std::string& get_local() const; + const std::string get_encoded_local() const; + const std::string& get_server() const; + + bool is_channel; + bool is_user; + + std::string get_sep() const; + + std::tuple<std::string, std::string> to_tuple() const; + +private: + + void init(const std::string& iid); + void init_with_fixed_server(const std::string& iid, const std::string& hostname); + + std::string local; + std::string server; +}; + +namespace std { + const std::string to_string(const Iid& iid); +} + + diff --git a/src/irc/irc_channel.cpp b/src/irc/irc_channel.cpp new file mode 100644 index 0000000..e769245 --- /dev/null +++ b/src/irc/irc_channel.cpp @@ -0,0 +1,60 @@ +#include <irc/irc_channel.hpp> +#include <algorithm> + +IrcChannel::IrcChannel(): + joined(false), + self(nullptr) +{ +} + +void IrcChannel::set_self(const std::string& name) +{ + this->self = std::make_unique<IrcUser>(name); +} + +IrcUser* IrcChannel::add_user(const std::string& name, + const std::map<char, char>& prefix_to_mode) +{ + this->users.emplace_back(std::make_unique<IrcUser>(name, prefix_to_mode)); + return this->users.back().get(); +} + +IrcUser* IrcChannel::get_self() const +{ + return this->self.get(); +} + +IrcUser* IrcChannel::find_user(const std::string& name) const +{ + IrcUser user(name); + for (const auto& u: this->users) + { + if (u->nick == user.nick) + return u.get(); + } + return nullptr; +} + +void IrcChannel::remove_user(const IrcUser* user) +{ + const auto nick = user->nick; + const auto it = std::find_if(this->users.begin(), this->users.end(), + [nick](const std::unique_ptr<IrcUser>& u) + { + return nick == u->nick; + }); + if (it != this->users.end()) + this->users.erase(it); +} + +void IrcChannel::remove_all_users() +{ + this->users.clear(); + this->self.reset(); +} + +DummyIrcChannel::DummyIrcChannel(): + IrcChannel(), + joining(false) +{ +} diff --git a/src/irc/irc_channel.hpp b/src/irc/irc_channel.hpp new file mode 100644 index 0000000..2bcefaf --- /dev/null +++ b/src/irc/irc_channel.hpp @@ -0,0 +1,70 @@ +#pragma once + + +#include <irc/irc_user.hpp> +#include <memory> +#include <string> +#include <vector> +#include <map> + +/** + * Keep the state of a joined channel (the list of occupants with their + * informations (mode, etc), the modes, etc) + */ +class IrcChannel +{ +public: + explicit IrcChannel(); + + IrcChannel(const IrcChannel&) = delete; + IrcChannel(IrcChannel&&) = delete; + IrcChannel& operator=(const IrcChannel&) = delete; + IrcChannel& operator=(IrcChannel&&) = delete; + + bool joined; + std::string topic; + std::string topic_author; + void set_self(const std::string& name); + IrcUser* get_self() const; + IrcUser* add_user(const std::string& name, + const std::map<char, char>& prefix_to_mode); + IrcUser* find_user(const std::string& name) const; + void remove_user(const IrcUser* user); + void remove_all_users(); + const std::vector<std::unique_ptr<IrcUser>>& get_users() const + { return this->users; } + +protected: + std::unique_ptr<IrcUser> self; + std::vector<std::unique_ptr<IrcUser>> users; +}; + +/** + * A special channel that is not actually linked to any real irc + * channel. This is just a channel representing a connection to the + * server. If an user wants to maintain the connection to the server without + * having to be on any irc channel of that server, he can just join this + * dummy channel. + * It’s not actually dummy because it’s useful and it does things, but well. + */ +class DummyIrcChannel: public IrcChannel +{ +public: + explicit DummyIrcChannel(); + DummyIrcChannel(const DummyIrcChannel&) = delete; + DummyIrcChannel(DummyIrcChannel&&) = delete; + DummyIrcChannel& operator=(const DummyIrcChannel&) = delete; + DummyIrcChannel& operator=(DummyIrcChannel&&) = delete; + + /** + * This flag is at true whenever the user wants to join this channel, but + * he is not yet connected to the server. When the connection is made, we + * check that flag and if it’s true, we inform the user that he has just + * joined that channel. + * If the user is already connected to the server when he tries to join + * the channel, we don’t use that flag, we just join it immediately. + */ + bool joining; +}; + + diff --git a/src/irc/irc_client.cpp b/src/irc/irc_client.cpp new file mode 100644 index 0000000..dd83307 --- /dev/null +++ b/src/irc/irc_client.cpp @@ -0,0 +1,1120 @@ +#include <utils/timed_events.hpp> +#include <database/database.hpp> +#include <irc/irc_message.hpp> +#include <irc/irc_client.hpp> +#include <bridge/bridge.hpp> +#include <irc/irc_user.hpp> + +#include <logger/logger.hpp> +#include <config/config.hpp> +#include <utils/tolower.hpp> +#include <utils/split.hpp> +#include <utils/string.hpp> + +#include <sstream> +#include <iostream> +#include <stdexcept> +#include <cstring> + +#include <chrono> +#include <string> + +#include "biboumi.h" +#include "louloulibs.h" + +using namespace std::string_literals; +using namespace std::chrono_literals; + +/** + * Define a map of functions to be called for each IRC command we can + * handle. + */ +using IrcCallback = void (IrcClient::*)(const IrcMessage&); + +static const std::unordered_map<std::string, + std::pair<IrcCallback, std::pair<std::size_t, std::size_t>>> irc_callbacks = { + {"NOTICE", {&IrcClient::on_notice, {2, 0}}}, + {"002", {&IrcClient::forward_server_message, {2, 0}}}, + {"003", {&IrcClient::forward_server_message, {2, 0}}}, + {"004", {&IrcClient::on_server_myinfo, {4, 0}}}, + {"005", {&IrcClient::on_isupport_message, {0, 0}}}, + {"RPL_LISTSTART", {&IrcClient::on_rpl_liststart, {0, 0}}}, + {"321", {&IrcClient::on_rpl_liststart, {0, 0}}}, + {"RPL_LIST", {&IrcClient::on_rpl_list, {0, 0}}}, + {"322", {&IrcClient::on_rpl_list, {0, 0}}}, + {"RPL_LISTEND", {&IrcClient::on_rpl_listend, {0, 0}}}, + {"323", {&IrcClient::on_rpl_listend, {0, 0}}}, + {"RPL_NOTOPIC", {&IrcClient::on_empty_topic, {0, 0}}}, + {"331", {&IrcClient::on_empty_topic, {0, 0}}}, + {"RPL_MOTDSTART", {&IrcClient::empty_motd, {0, 0}}}, + {"375", {&IrcClient::empty_motd, {0, 0}}}, + {"RPL_MOTD", {&IrcClient::on_motd_line, {2, 0}}}, + {"372", {&IrcClient::on_motd_line, {2, 0}}}, + {"RPL_MOTDEND", {&IrcClient::send_motd, {0, 0}}}, + {"376", {&IrcClient::send_motd, {0, 0}}}, + {"JOIN", {&IrcClient::on_channel_join, {1, 0}}}, + {"PRIVMSG", {&IrcClient::on_channel_message, {2, 0}}}, + {"353", {&IrcClient::set_and_forward_user_list, {4, 0}}}, + {"332", {&IrcClient::on_topic_received, {2, 0}}}, + {"TOPIC", {&IrcClient::on_topic_received, {2, 0}}}, + {"333", {&IrcClient::on_topic_who_time_received, {4, 0}}}, + {"RPL_TOPICWHOTIME", {&IrcClient::on_topic_who_time_received, {4, 0}}}, + {"366", {&IrcClient::on_channel_completely_joined, {2, 0}}}, + {"396", {&IrcClient::on_own_host_received, {2, 0}}}, + {"432", {&IrcClient::on_erroneous_nickname, {2, 0}}}, + {"433", {&IrcClient::on_nickname_conflict, {2, 0}}}, + {"438", {&IrcClient::on_nickname_change_too_fast, {2, 0}}}, + {"001", {&IrcClient::on_welcome_message, {1, 0}}}, + {"PART", {&IrcClient::on_part, {1, 0}}}, + {"ERROR", {&IrcClient::on_error, {1, 0}}}, + {"QUIT", {&IrcClient::on_quit, {0, 0}}}, + {"NICK", {&IrcClient::on_nick, {1, 0}}}, + {"MODE", {&IrcClient::on_mode, {1, 0}}}, + {"PING", {&IrcClient::send_pong_command, {1, 0}}}, + {"PONG", {&IrcClient::on_pong, {0, 0}}}, + {"KICK", {&IrcClient::on_kick, {3, 0}}}, + + {"401", {&IrcClient::on_generic_error, {2, 0}}}, + {"402", {&IrcClient::on_generic_error, {2, 0}}}, + {"403", {&IrcClient::on_generic_error, {2, 0}}}, + {"404", {&IrcClient::on_generic_error, {2, 0}}}, + {"405", {&IrcClient::on_generic_error, {2, 0}}}, + {"406", {&IrcClient::on_generic_error, {2, 0}}}, + {"407", {&IrcClient::on_generic_error, {2, 0}}}, + {"408", {&IrcClient::on_generic_error, {2, 0}}}, + {"409", {&IrcClient::on_generic_error, {2, 0}}}, + {"410", {&IrcClient::on_generic_error, {2, 0}}}, + {"411", {&IrcClient::on_generic_error, {2, 0}}}, + {"412", {&IrcClient::on_generic_error, {2, 0}}}, + {"414", {&IrcClient::on_generic_error, {2, 0}}}, + {"421", {&IrcClient::on_generic_error, {2, 0}}}, + {"422", {&IrcClient::on_generic_error, {2, 0}}}, + {"423", {&IrcClient::on_generic_error, {2, 0}}}, + {"424", {&IrcClient::on_generic_error, {2, 0}}}, + {"431", {&IrcClient::on_generic_error, {2, 0}}}, + {"436", {&IrcClient::on_generic_error, {2, 0}}}, + {"441", {&IrcClient::on_generic_error, {2, 0}}}, + {"442", {&IrcClient::on_generic_error, {2, 0}}}, + {"443", {&IrcClient::on_generic_error, {2, 0}}}, + {"444", {&IrcClient::on_generic_error, {2, 0}}}, + {"446", {&IrcClient::on_generic_error, {2, 0}}}, + {"451", {&IrcClient::on_generic_error, {2, 0}}}, + {"461", {&IrcClient::on_generic_error, {2, 0}}}, + {"462", {&IrcClient::on_generic_error, {2, 0}}}, + {"463", {&IrcClient::on_generic_error, {2, 0}}}, + {"464", {&IrcClient::on_generic_error, {2, 0}}}, + {"465", {&IrcClient::on_generic_error, {2, 0}}}, + {"467", {&IrcClient::on_generic_error, {2, 0}}}, + {"470", {&IrcClient::on_generic_error, {2, 0}}}, + {"471", {&IrcClient::on_generic_error, {2, 0}}}, + {"472", {&IrcClient::on_generic_error, {2, 0}}}, + {"473", {&IrcClient::on_generic_error, {2, 0}}}, + {"474", {&IrcClient::on_generic_error, {2, 0}}}, + {"475", {&IrcClient::on_generic_error, {2, 0}}}, + {"476", {&IrcClient::on_generic_error, {2, 0}}}, + {"477", {&IrcClient::on_generic_error, {2, 0}}}, + {"481", {&IrcClient::on_generic_error, {2, 0}}}, + {"482", {&IrcClient::on_generic_error, {2, 0}}}, + {"483", {&IrcClient::on_generic_error, {2, 0}}}, + {"484", {&IrcClient::on_generic_error, {2, 0}}}, + {"485", {&IrcClient::on_generic_error, {2, 0}}}, + {"487", {&IrcClient::on_generic_error, {2, 0}}}, + {"491", {&IrcClient::on_generic_error, {2, 0}}}, + {"501", {&IrcClient::on_generic_error, {2, 0}}}, + {"502", {&IrcClient::on_generic_error, {2, 0}}}, +}; + +IrcClient::IrcClient(std::shared_ptr<Poller> poller, const std::string& hostname, + const std::string& nickname, const std::string& username, + const std::string& realname, const std::string& user_hostname, + Bridge& bridge): + TCPSocketHandler(poller), + hostname(hostname), + user_hostname(user_hostname), + username(username), + realname(realname), + current_nick(nickname), + bridge(bridge), + welcomed(false), + chanmodes({"", "", "", ""}), + chantypes({'#', '&'}) +{ + this->dummy_channel.topic = "This is a virtual channel provided for " + "convenience by biboumi, it is not connected " + "to any actual IRC channel of the server '" + this->hostname + + "', and sending messages in it has no effect. " + "Its main goal is to keep the connection to the IRC server " + "alive without having to join a real channel of that server. " + "To disconnect from the IRC server, leave this room and all " + "other IRC channels of that server."; +#ifdef USE_DATABASE + auto options = Database::get_irc_server_options(this->bridge.get_bare_jid(), + this->get_hostname()); + std::vector<std::string> ports = utils::split(options.ports, ';', false); + for (auto it = ports.rbegin(); it != ports.rend(); ++it) + this->ports_to_try.emplace(*it, false); +# ifdef BOTAN_FOUND + ports = utils::split(options.tlsPorts, ';', false); + for (auto it = ports.rbegin(); it != ports.rend(); ++it) + this->ports_to_try.emplace(*it, true); +# endif // BOTAN_FOUND + +#else // not USE_DATABASE + this->ports_to_try.emplace("6667", false); // standard non-encrypted port +# ifdef BOTAN_FOUND + this->ports_to_try.emplace("6670", true); // non-standard but I want it for some servers + this->ports_to_try.emplace("6697", true); // standard encrypted port +# endif // BOTAN_FOUND +#endif // USE_DATABASE +} + +IrcClient::~IrcClient() +{ + // This event may or may not exist (if we never got connected, it + // doesn't), but it's ok + TimedEventsManager::instance().cancel("PING"s + this->hostname + this->bridge.get_jid()); +} + +void IrcClient::start() +{ + if (this->is_connecting() || this->is_connected()) + return; + std::string port; + bool tls; + std::tie(port, tls) = this->ports_to_try.top(); + this->ports_to_try.pop(); + this->bridge.send_xmpp_message(this->hostname, "", "Connecting to "s + + this->hostname + ":" + port + " (" + + (tls ? "encrypted" : "not encrypted") + ")"); + + this->bind_addr = Config::get("outgoing_bind", ""); + +#ifdef BOTAN_FOUND +# ifdef USE_DATABASE + auto options = Database::get_irc_server_options(this->bridge.get_bare_jid(), + this->get_hostname()); + this->credential_manager.set_trusted_fingerprint(options.trustedFingerprint); +# endif +#endif + this->connect(this->hostname, port, tls); +} + +void IrcClient::on_connection_failed(const std::string& reason) +{ + this->bridge.send_xmpp_message(this->hostname, "", + "Connection failed: "s + reason); + + if (this->hostname_resolution_failed) + while (!this->ports_to_try.empty()) + this->ports_to_try.pop(); + + if (this->ports_to_try.empty()) + { + // Send an error message for all room that the user wanted to join + for (const auto& tuple: this->channels_to_join) + { + Iid iid(std::get<0>(tuple) + "%" + this->hostname); + this->bridge.send_presence_error(iid, this->current_nick, + "cancel", "item-not-found", + "", reason); + } + } + else // try the next port + this->start(); +} + +void IrcClient::on_connected() +{ + const auto webirc_password = Config::get("webirc_password", ""); + static std::string resolved_ip; + + if (!webirc_password.empty()) + { + if (!resolved_ip.empty()) + this->send_webirc_command(webirc_password, resolved_ip); + else + { // Start resolving the hostname of the user, and call + // on_connected again when it’s done + this->dns_resolver.resolve(this->user_hostname, "5222", + [this](const struct addrinfo* addr) + { + resolved_ip = addr_to_string(addr); + // Only continue the process if we + // didn’t get connected while we were + // resolving + if (this->is_connected()) + this->on_connected(); + }, + [this](const char* error_msg) + { + if (this->is_connected()) + { + this->on_connection_close("Could not resolve hostname "s + this->user_hostname + + ": " + error_msg); + this->send_quit_command(""); + } + }); + return; + } + } + + this->send_message({"CAP", {"REQ", "multi-prefix"}}); + this->send_message({"CAP", {"END"}}); + +#ifdef USE_DATABASE + auto options = Database::get_irc_server_options(this->bridge.get_bare_jid(), + this->get_hostname()); + if (!options.pass.value().empty()) + this->send_pass_command(options.pass.value()); +#endif + + this->send_nick_command(this->current_nick); + +#ifdef USE_DATABASE + if (Config::get("realname_customization", "true") == "true") + { + if (!options.username.value().empty()) + this->username = options.username.value(); + if (!options.realname.value().empty()) + this->realname = options.realname.value(); + this->send_user_command(username, realname); + } + else + this->send_user_command(this->username, this->realname); +#else + this->send_user_command(this->username, this->realname); +#endif + this->send_gateway_message("Connected to IRC server"s + (this->use_tls ? " (encrypted)": "") + "."); + this->send_pending_data(); +} + +void IrcClient::on_connection_close(const std::string& error_msg) +{ + std::string message = "Connection closed"; + if (!error_msg.empty()) + message += ": " + error_msg; + else + message += "."; + const IrcMessage error{"ERROR", {message}}; + this->on_error(error); + log_warning(message); +} + +IrcChannel* IrcClient::get_channel(const std::string& n) +{ + if (n.empty()) + return &this->dummy_channel; + const std::string name = utils::tolower(n); + try + { + return this->channels.at(name).get(); + } + catch (const std::out_of_range& exception) + { + this->channels.emplace(name, std::make_unique<IrcChannel>()); + return this->channels.at(name).get(); + } +} + +bool IrcClient::is_channel_joined(const std::string& name) +{ + IrcChannel* channel = this->get_channel(name); + return channel->joined; +} + +std::string IrcClient::get_own_nick() const +{ + return this->current_nick; +} + +void IrcClient::parse_in_buffer(const size_t) +{ + while (true) + { + auto pos = this->in_buf.find("\r\n"); + if (pos == std::string::npos) + break ; + IrcMessage message(this->in_buf.substr(0, pos)); + this->in_buf = this->in_buf.substr(pos + 2, std::string::npos); + log_debug("IRC RECEIVING: (", this->get_hostname(), ") ", message); + + // Call the standard callback (if any), associated with the command + // name that we just received. + auto it = irc_callbacks.find(message.command); + if (it != irc_callbacks.end()) + { + const auto& limits = it->second.second; + // Check that the Message is well formed before actually calling + // the callback. limits.first is the min number of arguments, + // second is the max + if (message.arguments.size() < limits.first || + (limits.second > 0 && message.arguments.size() > limits.second)) + log_warning("Invalid number of arguments for IRC command “", message.command, + "”: ", message.arguments.size()); + else + { + const auto& cb = it->second.first; + try { + (this->*(cb))(message); + } catch (const std::exception& e) { + log_error("Unhandled exception: ", e.what()); + } + } + } + else + { + log_info("No handler for command ", message.command, + ", forwarding the arguments to the user"); + this->on_unknown_message(message); + } + // Try to find a waiting_iq, which response will be triggered by this IrcMessage + this->bridge.trigger_on_irc_message(this->hostname, message); + } +} + +void IrcClient::send_message(IrcMessage&& message) +{ + log_debug("IRC SENDING: (", this->get_hostname(), ") ", message); + std::string res; + if (!message.prefix.empty()) + res += ":" + std::move(message.prefix) + " "; + res += std::move(message.command); + for (const std::string& arg: message.arguments) + { + if (arg.find(" ") != std::string::npos || + (!arg.empty() && arg[0] == ':')) + { + res += " :" + arg; + break; + } + res += " " + arg; + } + res += "\r\n"; + this->send_data(std::move(res)); +} + +void IrcClient::send_raw(const std::string& txt) +{ + log_debug("IRC SENDING (raw): (", this->get_hostname(), ") ", txt); + this->send_data(txt + "\r\n"); +} + +void IrcClient::send_user_command(const std::string& username, const std::string& realname) +{ + this->send_message(IrcMessage("USER", {username, this->user_hostname, "ignored", realname})); +} + +void IrcClient::send_nick_command(const std::string& nick) +{ + this->send_message(IrcMessage("NICK", {nick})); +} + +void IrcClient::send_pass_command(const std::string& password) +{ + this->send_message(IrcMessage("PASS", {password})); +} + +void IrcClient::send_webirc_command(const std::string& password, const std::string& user_ip) +{ + this->send_message(IrcMessage("WEBIRC", {password, "biboumi", this->user_hostname, user_ip})); +} + +void IrcClient::send_kick_command(const std::string& chan_name, const std::string& target, const std::string& reason) +{ + this->send_message(IrcMessage("KICK", {chan_name, target, reason})); +} + +void IrcClient::send_list_command() +{ + this->send_message(IrcMessage("LIST", {})); +} + +void IrcClient::send_topic_command(const std::string& chan_name, const std::string& topic) +{ + this->send_message(IrcMessage("TOPIC", {chan_name, topic})); +} + +void IrcClient::send_quit_command(const std::string& reason) +{ + this->send_message(IrcMessage("QUIT", {reason})); +} + +void IrcClient::send_join_command(const std::string& chan_name, const std::string& password) +{ + if (this->welcomed == false) + this->channels_to_join.emplace_back(chan_name, password); + else if (password.empty()) + this->send_message(IrcMessage("JOIN", {chan_name})); + else + this->send_message(IrcMessage("JOIN", {chan_name, password})); + this->start(); +} + +bool IrcClient::send_channel_message(const std::string& chan_name, const std::string& body) +{ + IrcChannel* channel = this->get_channel(chan_name); + if (channel->joined == false) + { + log_warning("Cannot send message to channel ", chan_name, ", it is not joined"); + return false; + } + // The max size is 512, taking into account the whole message, not just + // the text we send. + // This includes our own nick, username and host (because this will be + // added by the server into our message), in addition to the basic + // components of the message we send (command name, chan name, \r\n et) + // : + NICK + ! + USER + @ + HOST + <space> + PRIVMSG + <space> + CHAN + <space> + : + \r\n + const auto line_size = 512 - + this->current_nick.size() - this->username.size() - this->own_host.size() - + ::strlen(":!@ PRIVMSG ") - chan_name.length() - ::strlen(" :\r\n"); + const auto lines = cut(body, line_size); + for (const auto& line: lines) + this->send_message(IrcMessage("PRIVMSG", {chan_name, line})); + return true; +} + +void IrcClient::send_private_message(const std::string& username, const std::string& body, const std::string& type) +{ + std::string::size_type pos = 0; + while (pos < body.size()) + { + this->send_message(IrcMessage(std::string(type), {username, body.substr(pos, 400)})); + pos += 400; + } + // We always try to insert and we don't care if the username was already + // in the set. + this->nicks_to_treat_as_private.insert(username); +} + +void IrcClient::send_part_command(const std::string& chan_name, const std::string& status_message) +{ + IrcChannel* channel = this->get_channel(chan_name); + if (channel->joined == true) + { + if (chan_name.empty()) + this->leave_dummy_channel(status_message); + else + this->send_message(IrcMessage("PART", {chan_name, status_message})); + } +} + +void IrcClient::send_mode_command(const std::string& chan_name, const std::vector<std::string>& arguments) +{ + std::vector<std::string> args(arguments); + args.insert(args.begin(), chan_name); + IrcMessage m("MODE", std::move(args)); + this->send_message(std::move(m)); +} + +void IrcClient::send_pong_command(const IrcMessage& message) +{ + const std::string id = message.arguments[0]; + this->send_message(IrcMessage("PONG", {id})); +} + +void IrcClient::on_pong(const IrcMessage&) +{ +} + +void IrcClient::send_ping_command() +{ + this->send_message(IrcMessage("PING", {"biboumi"})); +} + +void IrcClient::forward_server_message(const IrcMessage& message) +{ + const std::string from = message.prefix; + const std::string body = message.arguments[1]; + + this->bridge.send_xmpp_message(this->hostname, from, body); +} + +void IrcClient::on_notice(const IrcMessage& message) +{ + std::string from = message.prefix; + const std::string to = message.arguments[0]; + const std::string body = message.arguments[1]; + + if (!body.empty() && body[0] == '\01' && body[body.size() - 1] == '\01') + // Do not forward the notice to the user if it's a CTCP command + return ; + + if (!to.empty() && this->chantypes.find(to[0]) == this->chantypes.end()) + { + // The notice is for us precisely. + + // Find out if we already sent a private message to this user. If yes + // we treat that message as a private message coming from + // it. Otherwise we treat it as a notice coming from the server. + IrcUser user(from); + std::string nick = utils::tolower(user.nick); + if (this->nicks_to_treat_as_private.find(nick) != + this->nicks_to_treat_as_private.end()) + { // We previously sent a message to that nick) + this->bridge.send_message({nick + "!" + this->hostname}, nick, body, + false); + } + else + this->bridge.send_xmpp_message(this->hostname, from, body); + } + else + { + // The notice was directed at a channel we are in. Modify the message + // to indicate that it is a notice, and make it a MUC message coming + // from the MUC JID + IrcMessage modified_message(std::move(from), "PRIVMSG", {to, "\u000303[notice]\u0003 "s + body}); + this->on_channel_message(modified_message); + } +} + +void IrcClient::on_isupport_message(const IrcMessage& message) +{ + const size_t len = message.arguments.size(); + for (size_t i = 1; i < len; ++i) + { + const std::string token = message.arguments[i]; + if (token.substr(0, 10) == "CHANMODES=") + { + this->chanmodes = utils::split(token.substr(11), ','); + // make sure we have 4 strings + this->chanmodes.resize(4); + } + else if (token.substr(0, 7) == "PREFIX=") + { + size_t i = 8; // jump PREFIX=( + size_t j = 9; + // Find the ) char + while (j < token.size() && token[j] != ')') + j++; + j++; + while (j < token.size() && token[i] != ')') + { + this->sorted_user_modes.push_back(token[i]); + this->prefix_to_mode[token[j++]] = token[i++]; + } + } + else if (token.substr(0, 10) == "CHANTYPES=") + { + // Remove the default types, they apply only if no other value is + // specified. + this->chantypes.clear(); + size_t i = 10; + while (i < token.size()) + this->chantypes.insert(token[i++]); + } + } +} + +void IrcClient::on_server_myinfo(const IrcMessage&) +{ +} + +void IrcClient::send_gateway_message(const std::string& message, const std::string& from) +{ + this->bridge.send_xmpp_message(this->hostname, from, message); +} + +void IrcClient::set_and_forward_user_list(const IrcMessage& message) +{ + const std::string chan_name = utils::tolower(message.arguments[2]); + IrcChannel* channel = this->get_channel(chan_name); + std::vector<std::string> nicks = utils::split(message.arguments[3], ' '); + for (const std::string& nick: nicks) + { + const IrcUser* user = channel->add_user(nick, this->prefix_to_mode); + if (user->nick != channel->get_self()->nick) + { + this->bridge.send_user_join(this->hostname, chan_name, user, user->get_most_significant_mode(this->sorted_user_modes), false); + } + else + { + // we now know the modes of self, so copy the modes into self + channel->get_self()->modes = user->modes; + } + } +} + +void IrcClient::on_channel_join(const IrcMessage& message) +{ + const std::string chan_name = utils::tolower(message.arguments[0]); + IrcChannel* channel; + if (chan_name.empty()) + channel = &this->dummy_channel; + else + channel = this->get_channel(chan_name); + const std::string nick = message.prefix; + if (channel->joined == false) + channel->set_self(nick); + else + { + const IrcUser* user = channel->add_user(nick, this->prefix_to_mode); + this->bridge.send_user_join(this->hostname, chan_name, user, user->get_most_significant_mode(this->sorted_user_modes), false); + } +} + +void IrcClient::on_channel_message(const IrcMessage& message) +{ + const IrcUser user(message.prefix); + const std::string nick = user.nick; + Iid iid; + iid.set_local(message.arguments[0]); + iid.set_server(this->hostname); + const std::string body = message.arguments[1]; + bool muc = true; + if (!this->get_channel(iid.get_local())->joined) + { + iid.is_user = true; + iid.set_local(nick); + muc = false; + } + else + iid.is_channel = true; + if (!body.empty() && body[0] == '\01') + { + if (body.substr(1, 6) == "ACTION") + this->bridge.send_message(iid, nick, + "/me"s + body.substr(7, body.size() - 8), muc); + else if (body.substr(1, 8) == "VERSION\01") + this->bridge.send_iq_version_request(nick, this->hostname); + else if (body.substr(1, 5) == "PING ") + this->bridge.send_xmpp_ping_request(utils::tolower(nick), this->hostname, + body.substr(6, body.size() - 7)); + } + else + this->bridge.send_message(iid, nick, body, muc); +} + +void IrcClient::on_rpl_liststart(const IrcMessage&) +{ +} + +void IrcClient::on_rpl_list(const IrcMessage&) +{ +} + +void IrcClient::on_rpl_listend(const IrcMessage&) +{ +} + +void IrcClient::empty_motd(const IrcMessage&) +{ + this->motd.erase(); +} + +void IrcClient::on_empty_topic(const IrcMessage& message) +{ + const std::string chan_name = utils::tolower(message.arguments[1]); + log_debug("empty topic for ", chan_name); + IrcChannel* channel = this->get_channel(chan_name); + if (channel) + channel->topic.clear(); +} + +void IrcClient::on_motd_line(const IrcMessage& message) +{ + const std::string body = message.arguments[1]; + // We could send the MOTD without a line break between each IRC-message, + // but sometimes it contains some ASCII art, we use line breaks to keep + // them intact. + this->motd += body+"\n"; +} + +void IrcClient::send_motd(const IrcMessage&) +{ + this->bridge.send_xmpp_message(this->hostname, "", this->motd); +} + +void IrcClient::on_topic_received(const IrcMessage& message) +{ + const std::string chan_name = utils::tolower(message.arguments[message.arguments.size() - 2]); + IrcUser author(message.prefix); + IrcChannel* channel = this->get_channel(chan_name); + channel->topic = message.arguments[message.arguments.size() - 1]; + channel->topic_author = author.nick; + if (channel->joined) + this->bridge.send_topic(this->hostname, chan_name, channel->topic, channel->topic_author); +} + +void IrcClient::on_topic_who_time_received(const IrcMessage& message) +{ + IrcUser author(message.arguments[2]); + const std::string chan_name = utils::tolower(message.arguments[1]); + IrcChannel* channel = this->get_channel(chan_name); + channel->topic_author = author.nick; +} + +void IrcClient::on_channel_completely_joined(const IrcMessage& message) +{ + const std::string chan_name = utils::tolower(message.arguments[1]); + IrcChannel* channel = this->get_channel(chan_name); + channel->joined = true; + this->bridge.send_user_join(this->hostname, chan_name, channel->get_self(), channel->get_self()->get_most_significant_mode(this->sorted_user_modes), true); + this->bridge.send_topic(this->hostname, chan_name, channel->topic, channel->topic_author); +} + +void IrcClient::on_own_host_received(const IrcMessage& message) +{ + this->own_host = message.arguments[1]; + const std::string from = message.prefix; + if (message.arguments.size() >= 3) + this->bridge.send_xmpp_message(this->hostname, from, + this->own_host + " " + message.arguments[2]); + else + this->bridge.send_xmpp_message(this->hostname, from, this->own_host + + " is now your displayed host"); +} + +void IrcClient::on_erroneous_nickname(const IrcMessage& message) +{ + const std::string error_msg = message.arguments.size() >= 3 ? + message.arguments[2]: "Erroneous nickname"; + this->send_gateway_message(error_msg + ": " + message.arguments[1], message.prefix); +} + +void IrcClient::on_nickname_conflict(const IrcMessage& message) +{ + const std::string nickname = message.arguments[1]; + this->on_generic_error(message); + for (auto it = this->channels.begin(); it != this->channels.end(); ++it) + { + Iid iid; + iid.set_local(it->first); + iid.set_server(this->hostname); + iid.is_channel = true; + this->bridge.send_nickname_conflict_error(iid, nickname); + } +} + +void IrcClient::on_nickname_change_too_fast(const IrcMessage& message) +{ + const std::string nickname = message.arguments[1]; + std::string txt; + if (message.arguments.size() >= 3) + txt = message.arguments[2]; + this->on_generic_error(message); + for (auto it = this->channels.begin(); it != this->channels.end(); ++it) + { + Iid iid; + iid.set_local(it->first); + iid.set_server(this->hostname); + iid.is_channel = true; + this->bridge.send_presence_error(iid, nickname, + "cancel", "not-acceptable", + "", txt); + } +} + +void IrcClient::on_generic_error(const IrcMessage& message) +{ + const std::string error_msg = message.arguments.size() >= 3 ? + message.arguments[2]: "Unspecified error"; + this->send_gateway_message(message.arguments[1] + ": " + error_msg, message.prefix); +} + +void IrcClient::on_welcome_message(const IrcMessage& message) +{ + this->current_nick = message.arguments[0]; + this->welcomed = true; +#ifdef USE_DATABASE + auto options = Database::get_irc_server_options(this->bridge.get_bare_jid(), + this->get_hostname()); + if (!options.afterConnectionCommand.value().empty()) + this->send_raw(options.afterConnectionCommand.value()); +#endif + // Install a repeated events to regularly send a PING + TimedEventsManager::instance().add_event(TimedEvent(240s, std::bind(&IrcClient::send_ping_command, this), + "PING"s + this->hostname + this->bridge.get_jid())); + for (const auto& tuple: this->channels_to_join) + this->send_join_command(std::get<0>(tuple), std::get<1>(tuple)); + this->channels_to_join.clear(); + // Indicate that the dummy channel is joined as well, if needed + if (this->dummy_channel.joining) + { + // Simulate a message coming from the IRC server saying that we joined + // the channel + const IrcMessage join_message(this->get_nick(), "JOIN", {""}); + this->on_channel_join(join_message); + const IrcMessage end_join_message(std::string(this->hostname), "366", + {this->get_nick(), + "", "End of NAMES list"}); + this->on_channel_completely_joined(end_join_message); + } +} + +void IrcClient::on_part(const IrcMessage& message) +{ + const std::string chan_name = message.arguments[0]; + IrcChannel* channel = this->get_channel(chan_name); + if (!channel->joined) + return ; + std::string txt; + if (message.arguments.size() >= 2) + txt = message.arguments[1]; + const IrcUser* user = channel->find_user(message.prefix); + if (user) + { + std::string nick = user->nick; + channel->remove_user(user); + Iid iid; + iid.set_local(chan_name); + iid.set_server(this->hostname); + iid.is_channel = true; + bool self = channel->get_self()->nick == nick; + if (self) + { + channel->joined = false; + this->channels.erase(utils::tolower(chan_name)); + // channel pointer is now invalid + channel = nullptr; + } + this->bridge.send_muc_leave(std::move(iid), std::move(nick), std::move(txt), self); + } +} + +void IrcClient::on_error(const IrcMessage& message) +{ + const std::string leave_message = message.arguments[0]; + // The user is out of all the channels + for (auto it = this->channels.begin(); it != this->channels.end(); ++it) + { + Iid iid; + iid.set_local(it->first); + iid.set_server(this->hostname); + iid.is_channel = true; + IrcChannel* channel = it->second.get(); + if (!channel->joined) + continue; + std::string own_nick = channel->get_self()->nick; + this->bridge.send_muc_leave(std::move(iid), std::move(own_nick), leave_message, true); + } + this->channels.clear(); + this->send_gateway_message("ERROR: "s + leave_message); +} + +void IrcClient::on_quit(const IrcMessage& message) +{ + std::string txt; + if (message.arguments.size() >= 1) + txt = message.arguments[0]; + for (auto it = this->channels.begin(); it != this->channels.end(); ++it) + { + const std::string chan_name = it->first; + IrcChannel* channel = it->second.get(); + const IrcUser* user = channel->find_user(message.prefix); + if (user) + { + std::string nick = user->nick; + channel->remove_user(user); + Iid iid; + iid.set_local(chan_name); + iid.set_server(this->hostname); + iid.is_channel = true; + this->bridge.send_muc_leave(std::move(iid), std::move(nick), txt, false); + } + } +} + +void IrcClient::on_nick(const IrcMessage& message) +{ + const std::string new_nick = message.arguments[0]; + for (auto it = this->channels.begin(); it != this->channels.end(); ++it) + { + const std::string chan_name = it->first; + IrcChannel* channel = it->second.get(); + IrcUser* user = channel->find_user(message.prefix); + if (user) + { + std::string old_nick = user->nick; + Iid iid; + iid.set_local(chan_name); + iid.set_server(this->hostname); + iid.is_channel = true; + const bool self = channel->get_self()->nick == old_nick; + const char user_mode = user->get_most_significant_mode(this->sorted_user_modes); + this->bridge.send_nick_change(std::move(iid), old_nick, new_nick, user_mode, self); + user->nick = new_nick; + if (self) + { + channel->get_self()->nick = new_nick; + this->current_nick = new_nick; + } + } + } +} + +void IrcClient::on_kick(const IrcMessage& message) +{ + const std::string chan_name = utils::tolower(message.arguments[0]); + const std::string target = message.arguments[1]; + const std::string reason = message.arguments[2]; + IrcChannel* channel = this->get_channel(chan_name); + if (!channel->joined) + return ; + if (channel->get_self()->nick == target) + channel->joined = false; + IrcUser author(message.prefix); + Iid iid; + iid.set_local(chan_name); + iid.set_server(this->hostname); + iid.is_channel = true; + this->bridge.kick_muc_user(std::move(iid), target, reason, author.nick); +} + +void IrcClient::on_mode(const IrcMessage& message) +{ + const std::string target = message.arguments[0]; + if (this->chantypes.find(target[0]) != this->chantypes.end()) + this->on_channel_mode(message); + else + this->on_user_mode(message); +} + +void IrcClient::on_channel_mode(const IrcMessage& message) +{ + // For now, just transmit the modes so the user can know what happens + // TODO, actually interprete the mode. + Iid iid; + iid.set_local(message.arguments[0]); + iid.set_server(this->hostname); + iid.is_channel = true; + IrcUser user(message.prefix); + std::string mode_arguments; + for (size_t i = 1; i < message.arguments.size(); ++i) + { + if (!message.arguments[i].empty()) + { + if (i != 1) + mode_arguments += " "; + mode_arguments += message.arguments[i]; + } + } + this->bridge.send_message(iid, "", "Mode "s + iid.get_local() + + " [" + mode_arguments + "] by " + user.nick, + true); + const IrcChannel* channel = this->get_channel(iid.get_local()); + if (!channel) + return; + + // parse the received modes, we need to handle things like "+m-oo coucou toutou" + const std::string modes = message.arguments[1]; + // a list of modified IrcUsers. When we applied all modes, we check the + // modes that now applies to each of them, and send a notification for + // each one. This is to disallow sending two notifications or more when a + // single MODE command changes two or more modes on the same participant + std::set<const IrcUser*> modified_users; + // If it is true, the modes are added, if it’s false they are + // removed. When we encounter the '+' char, the value is changed to true, + // and with '-' it is changed to false. + bool add = true; + bool use_arg; + size_t arg_pos = 2; + for (const char c: modes) + { + if (c == '+') + add = true; + else if (c == '-') + add = false; + else + { // lookup the mode symbol in the 4 chanmodes lists, depending on + // the list where it is found, it takes an argument or not + size_t type; + for (type = 0; type < 4; ++type) + if (this->chanmodes[type].find(c) != std::string::npos) + break; + if (type == 4) // if mode was not found + { + // That mode can also be of type B if it is present in the + // prefix_to_mode map + for (const std::pair<char, char>& pair: this->prefix_to_mode) + if (pair.second == c) + { + type = 1; + break; + } + } + // modes of type A, B or C (but only with add == true) + if (type == 0 || type == 1 || + (type == 2 && add == true)) + use_arg = true; + else // modes of type C (but only with add == false), D, or unknown + use_arg = false; + if (use_arg == true && message.arguments.size() > arg_pos) + { + const std::string target = message.arguments[arg_pos++]; + IrcUser* user = channel->find_user(target); + if (!user) + { + log_warning("Trying to set mode for non-existing user '", target + , "' in channel", iid.get_local()); + return; + } + if (add) + user->add_mode(c); + else + user->remove_mode(c); + modified_users.insert(user); + } + } + } + for (const IrcUser* u: modified_users) + { + char most_significant_mode = u->get_most_significant_mode(this->sorted_user_modes); + this->bridge.send_affiliation_role_change(iid, u->nick, most_significant_mode); + } +} + +void IrcClient::on_user_mode(const IrcMessage& message) +{ + this->bridge.send_xmpp_message(this->hostname, "", + "User mode for "s + message.arguments[0] + + " is [" + message.arguments[1] + "]"); +} + +void IrcClient::on_unknown_message(const IrcMessage& message) +{ + if (message.arguments.size() < 2) + return ; + std::string from = message.prefix; + std::stringstream ss; + for (auto it = message.arguments.begin() + 1; it != message.arguments.end(); ++it) + { + ss << *it; + if (it + 1 != message.arguments.end()) + ss << " "; + } + this->bridge.send_xmpp_message(this->hostname, from, ss.str()); +} + +size_t IrcClient::number_of_joined_channels() const +{ + if (this->dummy_channel.joined) + return this->channels.size() + 1; + else + return this->channels.size(); +} + +DummyIrcChannel& IrcClient::get_dummy_channel() +{ + return this->dummy_channel; +} + +void IrcClient::leave_dummy_channel(const std::string& exit_message) +{ + if (!this->dummy_channel.joined) + return; + this->dummy_channel.joined = false; + this->dummy_channel.joining = false; + this->dummy_channel.remove_all_users(); + this->bridge.send_muc_leave(Iid("%"s + this->hostname), std::string(this->current_nick), exit_message, true); +} + +#ifdef BOTAN_FOUND +bool IrcClient::abort_on_invalid_cert() const +{ +#ifdef USE_DATABASE + auto options = Database::get_irc_server_options(this->bridge.get_bare_jid(), this->hostname); + return options.verifyCert.value(); +#endif + return true; +} +#endif diff --git a/src/irc/irc_client.hpp b/src/irc/irc_client.hpp new file mode 100644 index 0000000..fc3918e --- /dev/null +++ b/src/irc/irc_client.hpp @@ -0,0 +1,383 @@ +#pragma once + + +#include <irc/irc_message.hpp> +#include <irc/irc_channel.hpp> +#include <irc/iid.hpp> + +#include <network/tcp_socket_handler.hpp> +#include <network/resolver.hpp> + +#include <unordered_map> +#include <utility> +#include <memory> +#include <vector> +#include <string> +#include <stack> +#include <map> +#include <set> + +class Bridge; + +/** + * Represent one IRC client, i.e. an endpoint connected to a single IRC + * server, through a TCP socket, receiving and sending commands to it. + */ +class IrcClient: public TCPSocketHandler +{ +public: + explicit IrcClient(std::shared_ptr<Poller> poller, const std::string& hostname, + const std::string& nickname, const std::string& username, + const std::string& realname, const std::string& user_hostname, + Bridge& bridge); + ~IrcClient(); + + IrcClient(const IrcClient&) = delete; + IrcClient(IrcClient&&) = delete; + IrcClient& operator=(const IrcClient&) = delete; + IrcClient& operator=(IrcClient&&) = delete; + + /** + * Connect to the IRC server + */ + void start(); + /** + * Called when the connection to the server cannot be established + */ + void on_connection_failed(const std::string& reason) override final; + /** + * Called when successfully connected to the server + */ + void on_connected() override final; + /** + * Close the connection, remove us from the poller + */ + void on_connection_close(const std::string& error) override final; + /** + * Parse the data we have received so far and try to get one or more + * complete messages from it. + */ + void parse_in_buffer(const size_t) override final; +#ifdef BOTAN_FOUND + virtual bool abort_on_invalid_cert() const override final; +#endif + /** + * Return the channel with this name, create it if it does not yet exist + */ + IrcChannel* get_channel(const std::string& name); + /** + * Returns true if the channel is joined + */ + bool is_channel_joined(const std::string& name); + /** + * Return our own nick + */ + std::string get_own_nick() const; + /** + * Serialize the given message into a line, and send that into the socket + * (actually, into our out_buf and signal the poller that we want to wach + * for send events to be ready) + */ + void send_message(IrcMessage&& message); + void send_raw(const std::string& txt); + /** + * Send the PONG irc command + */ + void send_pong_command(const IrcMessage& message); + /** + * Do nothing when we receive a PONG command (but also do not log that no + * handler exist) + */ + void on_pong(const IrcMessage& message); + void send_ping_command(); + /** + * Send the USER irc command + */ + void send_user_command(const std::string& username, const std::string& realname); + /** + * Send the NICK irc command + */ + void send_nick_command(const std::string& username); + void send_pass_command(const std::string& password); + void send_webirc_command(const std::string& password, const std::string& user_ip); + /** + * Send the JOIN irc command. + */ + void send_join_command(const std::string& chan_name, const std::string& password); + /** + * Send a PRIVMSG command for a channel + * Return true if the message was actually sent + */ + bool send_channel_message(const std::string& chan_name, const std::string& body); + /** + * Send a PRIVMSG command for an user + */ + void send_private_message(const std::string& username, const std::string& body, const std::string& type); + /** + * Send the PART irc command + */ + void send_part_command(const std::string& chan_name, const std::string& status_message); + /** + * Send the MODE irc command + */ + void send_mode_command(const std::string& chan_name, const std::vector<std::string>& arguments); + /** + * Send the KICK irc command + */ + void send_kick_command(const std::string& chan_name, const std::string& target, const std::string& reason); + /** + * Send the LIST irc command + */ + void send_list_command(); + void send_topic_command(const std::string& chan_name, const std::string& topic); + /** + * Send the QUIT irc command + */ + void send_quit_command(const std::string& reason); + /** + * Send a message to the gateway user, not generated by the IRC server, + * but that might be useful because we want to be verbose (for example we + * might want to notify the user about the connexion state) + */ + void send_gateway_message(const std::string& message, const std::string& from=""); + /** + * Forward the server message received from IRC to the XMPP component + */ + void forward_server_message(const IrcMessage& message); + /** + * When receiving the isupport informations. See + * http://www.irc.org/tech_docs/draft-brocklesby-irc-isupport-03.txt + */ + void on_isupport_message(const IrcMessage& message); + /** + * Does nothing yet. Isn’t that duplicating features from 005? + */ + void on_server_myinfo(const IrcMessage& message); + /** + * Just empty the motd we kept as a string + */ + void empty_motd(const IrcMessage& message); + /** + * Send the MOTD string as one single "big" message + */ + void send_motd(const IrcMessage& message); + /** + * Append this line to the MOTD + */ + void on_motd_line(const IrcMessage& message); + /** + * Forward the join of an other user into an IRC channel, and save the + * IrcUsers in the IrcChannel + */ + void set_and_forward_user_list(const IrcMessage& message); + /** + * Signal the start of the LIST response. The RFC says its obsolete and + * “not used”, but I we receive it on some servers, so just ignore it. + */ + void on_rpl_liststart(const IrcMessage& message); + /** + * A single LIST response line (one channel) + * + * The command is handled in a wait_irc callback. This general handler is + * empty and just used to avoid sending a message stanza for each received + * channel. + */ + void on_rpl_list(const IrcMessage& message); + /** + * Signal the end of the LIST response, ignore. + */ + void on_rpl_listend(const IrcMessage& message); + /** + * Remember our nick and host, when we are joined to the channel. The list + * of user comes after so we do not send the self-presence over XMPP yet. + */ + void on_channel_join(const IrcMessage& message); + /** + * When a channel message is received + */ + void on_channel_message(const IrcMessage& message); + /** + * A notice is received + */ + void on_notice(const IrcMessage& message); + /** + * Save the topic in the IrcChannel + */ + void on_topic_received(const IrcMessage& message); + /** + * Save the topic author in the IrcChannel + */ + void on_topic_who_time_received(const IrcMessage& message); + /** + * Empty the topic + */ + void on_empty_topic(const IrcMessage& message); + /** + * The channel has been completely joined (self presence, topic, all names + * received etc), send the self presence and topic to the XMPP user. + */ + void on_channel_completely_joined(const IrcMessage& message); + /** + * Save our own host, as reported by the server + */ + void on_own_host_received(const IrcMessage& message); + /** + * We tried to set an invalid nickname + */ + void on_erroneous_nickname(const IrcMessage& message); + /** + * When the IRC servers denies our nickname because of a conflict. Send a + * presence conflict from all channels, because the name is server-wide. + */ + void on_nickname_conflict(const IrcMessage& message); + /** + * Idem, but for when the user changes their nickname too quickly + */ + void on_nickname_change_too_fast(const IrcMessage& message); + /** + * Handles most errors from the server by just forwarding the message to the user. + */ + void on_generic_error(const IrcMessage& message); + /** + * When a message 001 is received, join the rooms we wanted to join, and set our actual nickname + */ + void on_welcome_message(const IrcMessage& message); + void on_part(const IrcMessage& message); + void on_error(const IrcMessage& message); + void on_nick(const IrcMessage& message); + void on_kick(const IrcMessage& message); + void on_mode(const IrcMessage& message); + /** + * A mode towards our own user is received (note, that is different from a + * channel mode towards or own nick, see + * http://tools.ietf.org/html/rfc2812#section-3.1.5 VS #section-3.2.3) + */ + void on_user_mode(const IrcMessage& message); + /** + * A mode towards a channel. Note that this can change the mode of the + * channel itself or an IrcUser in it. + */ + void on_channel_mode(const IrcMessage& message); + void on_quit(const IrcMessage& message); + void on_unknown_message(const IrcMessage& message); + /** + * Return the number of joined channels + */ + size_t number_of_joined_channels() const; + /** + * Get a reference to the unique dummy channel + */ + DummyIrcChannel& get_dummy_channel(); + /** + * Leave the dummy channel: forward a message to the user to indicate that + * he left it, and mark it as not joined. + */ + void leave_dummy_channel(const std::string& exit_message); + + const std::string& get_hostname() const { return this->hostname; } + std::string get_nick() const { return this->current_nick; } + bool is_welcomed() const { return this->welcomed; } + + const Resolver& get_resolver() const { return this->dns_resolver; } + + const std::vector<char>& get_sorted_user_modes() const { return sorted_user_modes; } + +private: + /** + * The hostname of the server we are connected to. + */ + const std::string hostname; + /** + * Our own host, as reported by the IRC server. + * By default (and if it is not overridden by the server), it is a + * meaningless string, with the maximum allowed size + */ + std::string own_host{63, '*'}; + /** + * The hostname of the user. This is used in the USER and the WEBIRC + * commands, but only the one in WEBIRC will be used by the IRC server. + */ + const std::string user_hostname; + /** + * The username used in the USER irc command + */ + std::string username; + /** + * The realname used in the USER irc command + */ + std::string realname; + /** + * Our current nickname on the server + */ + std::string current_nick; + /** + * To communicate back with the bridge + */ + Bridge& bridge; + /** + * The list of joined channels, indexed by name + */ + std::unordered_map<std::string, std::unique_ptr<IrcChannel>> channels; + /** + * A single channel with a iid of the form "hostname" (normal channel have + * an iid of the form "chan%hostname". + */ + DummyIrcChannel dummy_channel; + /** + * A list of chan we want to join (tuples with the channel name and the + * password, if any), but we need a response 001 from the server before + * sending the actual JOIN commands. So we just keep the channel names in + * a list, and send the JOIN commands for each of them whenever the + * WELCOME message is received. + */ + std::vector<std::tuple<std::string, std::string>> channels_to_join; + /** + * This flag indicates that the server is completely joined (connection + * has been established, we are authentified and we have a nick) + */ + bool welcomed; + /** + * See http://www.irc.org/tech_docs/draft-brocklesby-irc-isupport-03.txt section 3.3 + * We store the possible chanmodes in this object. + * chanmodes[0] contains modes of type A, [1] of type B etc + */ + std::vector<std::string> chanmodes; + /** + * See http://www.irc.org/tech_docs/draft-brocklesby-irc-isupport-03.txt + * section 3.5 + */ + std::set<char> chantypes; + /** + * Each motd line received is appended to this string, which we send when + * the motd is completely received + */ + std::string motd; + /** + * See http://www.irc.org/tech_docs/draft-brocklesby-irc-isupport-03.txt section 3.14 + * The example given would be transformed into + * modes_to_prefix = {{'&', 'a'}, {'*', 'b'}} + */ + std::map<char, char> prefix_to_mode; + /** + * Available user modes, sorted from most significant to least significant + * (for example 'ahov' is a common order). + */ + std::vector<char> sorted_user_modes; + /** + * A list of ports to which we will try to connect, in reverse. Each port + * is associated with a boolean telling if we should use TLS or not if the + * connection succeeds on that port. + */ + std::stack<std::pair<std::string, bool>> ports_to_try; + /** + * A set of (lowercase) nicknames to which we sent a private message. + */ + std::set<std::string> nicks_to_treat_as_private; + /** + * DNS resolver, used to resolve the hostname of the user if we are using + * the WebIRC protocole. + */ + Resolver dns_resolver; +}; + + diff --git a/src/irc/irc_message.cpp b/src/irc/irc_message.cpp new file mode 100644 index 0000000..966a47c --- /dev/null +++ b/src/irc/irc_message.cpp @@ -0,0 +1,61 @@ +#include <irc/irc_message.hpp> +#include <iostream> + +IrcMessage::IrcMessage(std::string&& line) +{ + std::string::size_type pos; + + // optional prefix + if (line[0] == ':') + { + pos = line.find(" "); + this->prefix = line.substr(1, pos - 1); + line = line.substr(pos + 1, std::string::npos); + } + // command + pos = line.find(" "); + this->command = line.substr(0, pos); + line = line.substr(pos + 1, std::string::npos); + // arguments + do + { + if (line[0] == ':') + { + this->arguments.emplace_back(line.substr(1, std::string::npos)); + break ; + } + pos = line.find(" "); + this->arguments.emplace_back(line.substr(0, pos)); + line = line.substr(pos + 1, std::string::npos); + } while (pos != std::string::npos); +} + +IrcMessage::IrcMessage(std::string&& prefix, + std::string&& command, + std::vector<std::string>&& args): + prefix(std::move(prefix)), + command(std::move(command)), + arguments(std::move(args)) +{ +} + +IrcMessage::IrcMessage(std::string&& command, + std::vector<std::string>&& args): + prefix(), + command(std::move(command)), + arguments(std::move(args)) +{ +} + +std::ostream& operator<<(std::ostream& os, const IrcMessage& message) +{ + os << "IrcMessage"; + os << "[" << message.command << "]"; + for (const std::string& arg: message.arguments) + { + os << "{" << arg << "}"; + } + if (!message.prefix.empty()) + os << "(from: " << message.prefix << ")"; + return os; +} diff --git a/src/irc/irc_message.hpp b/src/irc/irc_message.hpp new file mode 100644 index 0000000..fe954e4 --- /dev/null +++ b/src/irc/irc_message.hpp @@ -0,0 +1,28 @@ +#pragma once + + +#include <vector> +#include <string> +#include <ostream> + +class IrcMessage +{ +public: + IrcMessage(std::string&& line); + IrcMessage(std::string&& prefix, std::string&& command, std::vector<std::string>&& args); + IrcMessage(std::string&& command, std::vector<std::string>&& args); + ~IrcMessage() = default; + + IrcMessage(const IrcMessage&) = delete; + IrcMessage(IrcMessage&&) = delete; + IrcMessage& operator=(const IrcMessage&) = delete; + IrcMessage& operator=(IrcMessage&&) = delete; + + std::string prefix; + std::string command; + std::vector<std::string> arguments; +}; + +std::ostream& operator<<(std::ostream& os, const IrcMessage& message); + + diff --git a/src/irc/irc_user.cpp b/src/irc/irc_user.cpp new file mode 100644 index 0000000..9fa3612 --- /dev/null +++ b/src/irc/irc_user.cpp @@ -0,0 +1,57 @@ +#include <irc/irc_user.hpp> + +#include <iostream> + +IrcUser::IrcUser(const std::string& name, + const std::map<char, char>& prefix_to_mode) +{ + if (name.empty()) + return ; + + // One or more prefix (with multi-prefix support) may come before the + // actual nick + std::string::size_type name_begin = 0; + while (name_begin != name.size()) + { + const auto prefix = prefix_to_mode.find(name[name_begin]); + // This is not a prefix + if (prefix == prefix_to_mode.end()) + break; + this->modes.insert(prefix->second); + name_begin++; + } + + const std::string::size_type sep = name.find("!", name_begin); + if (sep == std::string::npos) + this->nick = name.substr(name_begin); + else + { + this->nick = name.substr(name_begin, sep-name_begin); + this->host = name.substr(sep+1); + } +} + +IrcUser::IrcUser(const std::string& name): + IrcUser(name, {}) +{ +} + +void IrcUser::add_mode(const char mode) +{ + this->modes.insert(mode); +} + +void IrcUser::remove_mode(const char mode) +{ + this->modes.erase(mode); +} + +char IrcUser::get_most_significant_mode(const std::vector<char>& modes) const +{ + for (const char mode: modes) + { + if (this->modes.find(mode) != this->modes.end()) + return mode; + } + return 0; +} diff --git a/src/irc/irc_user.hpp b/src/irc/irc_user.hpp new file mode 100644 index 0000000..c84030e --- /dev/null +++ b/src/irc/irc_user.hpp @@ -0,0 +1,33 @@ +#pragma once + + +#include <vector> +#include <string> +#include <map> +#include <set> + +/** + * Keeps various information about one IRC channel user + */ +class IrcUser +{ +public: + explicit IrcUser(const std::string& name, + const std::map<char, char>& prefix_to_mode); + explicit IrcUser(const std::string& name); + + IrcUser(const IrcUser&) = delete; + IrcUser(IrcUser&&) = delete; + IrcUser& operator=(const IrcUser&) = delete; + IrcUser& operator=(IrcUser&&) = delete; + + void add_mode(const char mode); + void remove_mode(const char mode); + char get_most_significant_mode(const std::vector<char>& sorted_user_modes) const; + + std::string nick; + std::string host; + std::set<char> modes; +}; + + diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..53f3193 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,202 @@ +#include <xmpp/biboumi_component.hpp> +#include <utils/timed_events.hpp> +#include <network/poller.hpp> +#include <config/config.hpp> +#include <logger/logger.hpp> +#include <utils/xdg.hpp> +#include <utils/reload.hpp> + +#ifdef CARES_FOUND +# include <network/dns_handler.hpp> +#endif + +#include <atomic> +#include <signal.h> + +// A flag set by the SIGINT signal handler. +static volatile std::atomic<bool> stop(false); +// Flag set by the SIGUSR1/2 signal handler. +static volatile std::atomic<bool> reload(false); +// A flag indicating that we are wanting to exit the process. i.e: if this +// flag is set and all connections are closed, we can exit properly. +static bool exiting = false; + +/** + * Provide an helpful message to help the user write a minimal working + * configuration file. + */ +int config_help(const std::string& missing_option) +{ + if (!missing_option.empty()) + log_error("Configuration error: empty value for option ", missing_option, "."); + log_error("Please provide a configuration file filled like this:\n\n" + "hostname=irc.example.com\npassword=S3CR3T"); + return 1; +} + +int display_help() +{ + std::cout << "Usage: biboumi [configuration_file]" << std::endl; + return 0; +} + +static void sigint_handler(int sig, siginfo_t*, void*) +{ + // In 2 seconds, repeat the same signal, to force the exit + TimedEventsManager::instance().add_event(TimedEvent(std::chrono::steady_clock::now() + 2s, + [sig]() { raise(sig); })); + stop.store(true); +} + +static void sigusr_handler(int, siginfo_t*, void*) +{ + reload.store(true); +} + +int main(int ac, char** av) +{ + if (ac > 1) + { + const std::string arg = av[1]; + if (arg.size() >= 2 && arg[0] == '-' && arg[1] == '-') + { + if (arg == "--help") + return display_help(); + else + { + std::cerr << "Unknow command line option: " << arg << std::endl; + return 1; + } + } + } + const std::string conf_filename = ac > 1 ? av[1] : xdg_config_path("biboumi.cfg"); + std::cout << "Using configuration file: " << conf_filename << std::endl; + + if (!Config::read_conf(conf_filename)) + return config_help(""); + + const std::string password = Config::get("password", ""); + if (password.empty()) + return config_help("password"); + const std::string hostname = Config::get("hostname", ""); + if (hostname.empty()) + return config_help("hostname"); + + try { + open_database(); + } catch (...) { + return 1; + } + + // Block the signals we want to manage. They will be unblocked only during + // the epoll_pwait or ppoll calls. This avoids some race conditions, + // explained in man 2 pselect on linux + sigset_t mask; + sigemptyset(&mask); + sigaddset(&mask, SIGINT); + sigaddset(&mask, SIGTERM); + sigaddset(&mask, SIGUSR1); + sigaddset(&mask, SIGUSR2); + sigprocmask(SIG_BLOCK, &mask, nullptr); + + // Install the signals used to exit the process cleanly, or reload the + // config + struct sigaction on_sigint; + on_sigint.sa_sigaction = &sigint_handler; + // All signals must be blocked while a signal handler is running + sigfillset(&on_sigint.sa_mask); + // we want to catch that signal only once. + // Sending SIGINT again will "force" an exit + on_sigint.sa_flags = SA_RESETHAND; + sigaction(SIGINT, &on_sigint, nullptr); + sigaction(SIGTERM, &on_sigint, nullptr); + + // Install a signal to reload the config on SIGUSR1/2 + struct sigaction on_sigusr; + on_sigusr.sa_sigaction = &sigusr_handler; + sigfillset(&on_sigusr.sa_mask); + on_sigusr.sa_flags = 0; + sigaction(SIGUSR1, &on_sigusr, nullptr); + sigaction(SIGUSR2, &on_sigusr, nullptr); + + auto p = std::make_shared<Poller>(); + auto xmpp_component = + std::make_shared<BiboumiComponent>(p, hostname, password); + xmpp_component->start(); + +#ifdef CARES_FOUND + DNSHandler::instance.watch_dns_sockets(p); +#endif + auto timeout = TimedEventsManager::instance().get_timeout(); + while (p->poll(timeout) != -1) + { + TimedEventsManager::instance().execute_expired_events(); + // Check for empty irc_clients (not connected, or with no joined + // channel) and remove them + xmpp_component->clean(); + if (stop) + { + log_info("Signal received, exiting..."); +#ifdef SYSTEMD_FOUND + sd_notify(0, "STOPPING=1"); +#endif + exiting = true; + stop.store(false); + xmpp_component->shutdown(); + // Cancel the timer for a potential reconnection + TimedEventsManager::instance().cancel("XMPP reconnection"); + } + if (reload) + { + log_info("Signal received, reloading the config..."); + ::reload_process(); + reload.store(false); + } + // Reconnect to the XMPP server if this was not intended. This may have + // happened because we sent something invalid to it and it decided to + // close the connection. This is a bug that should be fixed, but we + // still reconnect automatically instead of dropping everything + if (!exiting && xmpp_component->ever_auth && + !xmpp_component->is_connected() && + !xmpp_component->is_connecting()) + { + if (xmpp_component->first_connection_try == true) + { // immediately re-try to connect + xmpp_component->reset(); + xmpp_component->start(); + } + else + { // Re-connecting failed, we now try only each few seconds + auto reconnect_later = [xmpp_component]() + { + xmpp_component->reset(); + xmpp_component->start(); + }; + TimedEvent event(std::chrono::steady_clock::now() + 2s, + reconnect_later, "XMPP reconnection"); + TimedEventsManager::instance().add_event(std::move(event)); + } + } + // If the only existing connection is the one to the XMPP component: + // close the XMPP stream. + if (exiting && xmpp_component->is_connecting()) + xmpp_component->close(); + if (exiting && p->size() == 1 && xmpp_component->is_document_open()) + xmpp_component->close_document(); +#ifdef CARES_FOUND + if (!exiting) + DNSHandler::instance.watch_dns_sockets(p); +#endif + if (exiting) // If we are exiting, do not wait for any timed event + timeout = utils::no_timeout; + else + timeout = TimedEventsManager::instance().get_timeout(); + } +#ifdef CARES_FOUND + DNSHandler::instance.destroy(); +#endif + if (!xmpp_component->ever_auth) + return 1; // To signal that the process did not properly start + log_info("All connections cleanly closed, have a nice day."); + return 0; +} diff --git a/src/utils/empty_if_fixed_server.hpp b/src/utils/empty_if_fixed_server.hpp new file mode 100644 index 0000000..9ccf5fd --- /dev/null +++ b/src/utils/empty_if_fixed_server.hpp @@ -0,0 +1,26 @@ +#pragma once + + +#include <string> + +#include <config/config.hpp> + +namespace utils +{ + inline std::string empty_if_fixed_server(std::string&& str) + { + if (!Config::get("fixed_irc_server", "").empty()) + return {}; + return str; + } + + inline std::string empty_if_fixed_server(const std::string& str) + { + if (!Config::get("fixed_irc_server", "").empty()) + return {}; + return str; + } + +} + + diff --git a/src/utils/reload.cpp b/src/utils/reload.cpp new file mode 100644 index 0000000..348c5b5 --- /dev/null +++ b/src/utils/reload.cpp @@ -0,0 +1,34 @@ +#include <utils/reload.hpp> +#include <database/database.hpp> +#include <config/config.hpp> +#include <utils/xdg.hpp> +#include <logger/logger.hpp> + +#include "biboumi.h" + +void open_database() +{ +#ifdef USE_DATABASE + const auto db_filename = Config::get("db_name", xdg_data_path("biboumi.sqlite")); + log_info("Opening database: ", db_filename); + Database::open(db_filename); + log_info("database successfully opened."); +#endif +} + +void reload_process() +{ + Config::read_conf(); + // Destroy the logger instance, to be recreated the next time a log + // line needs to be written + Logger::instance().reset(); + log_info("Configuration and logger reloaded."); +#ifdef USE_DATABASE + try { + open_database(); + } catch (const litesql::DatabaseError&) { + log_warning("Re-using the previous database."); + } +#endif +} + diff --git a/src/utils/reload.hpp b/src/utils/reload.hpp new file mode 100644 index 0000000..408426a --- /dev/null +++ b/src/utils/reload.hpp @@ -0,0 +1,4 @@ +#pragma once + +void open_database(); +void reload_process(); diff --git a/src/xmpp/biboumi_adhoc_commands.cpp b/src/xmpp/biboumi_adhoc_commands.cpp new file mode 100644 index 0000000..eec930d --- /dev/null +++ b/src/xmpp/biboumi_adhoc_commands.cpp @@ -0,0 +1,635 @@ +#include <xmpp/biboumi_adhoc_commands.hpp> +#include <xmpp/biboumi_component.hpp> +#include <config/config.hpp> +#include <utils/string.hpp> +#include <utils/split.hpp> +#include <xmpp/jid.hpp> + +#include <biboumi.h> + +#ifdef USE_DATABASE +#include <database/database.hpp> +#endif + +#include <louloulibs.h> + +#include <algorithm> + +using namespace std::string_literals; + +void DisconnectUserStep1(XmppComponent& xmpp_component, AdhocSession&, XmlNode& command_node) +{ + auto& biboumi_component = static_cast<BiboumiComponent&>(xmpp_component); + + XmlNode x("jabber:x:data:x"); + x["type"] = "form"; + XmlNode title("title"); + title.set_inner("Disconnect a user from the gateway"); + x.add_child(std::move(title)); + XmlNode instructions("instructions"); + instructions.set_inner("Choose a user JID and a quit message"); + x.add_child(std::move(instructions)); + XmlNode jids_field("field"); + jids_field["var"] = "jids"; + jids_field["type"] = "list-multi"; + jids_field["label"] = "The JIDs to disconnect"; + XmlNode required("required"); + jids_field.add_child(std::move(required)); + for (Bridge* bridge: biboumi_component.get_bridges()) + { + XmlNode option("option"); + option["label"] = bridge->get_jid(); + XmlNode value("value"); + value.set_inner(bridge->get_jid()); + option.add_child(std::move(value)); + jids_field.add_child(std::move(option)); + } + x.add_child(std::move(jids_field)); + + XmlNode message_field("field"); + message_field["var"] = "quit-message"; + message_field["type"] = "text-single"; + message_field["label"] = "Quit message"; + XmlNode message_value("value"); + message_value.set_inner("Disconnected by admin"); + message_field.add_child(std::move(message_value)); + x.add_child(std::move(message_field)); + command_node.add_child(std::move(x)); +} + +void DisconnectUserStep2(XmppComponent& xmpp_component, AdhocSession& session, XmlNode& command_node) +{ + auto& biboumi_component = static_cast<BiboumiComponent&>(xmpp_component); + + // Find out if the jids, and the quit message are provided in the form. + std::string quit_message; + const XmlNode* x = command_node.get_child("x", "jabber:x:data"); + if (x) + { + const XmlNode* message_field = nullptr; + const XmlNode* jids_field = nullptr; + for (const XmlNode* field: x->get_children("field", "jabber:x:data")) + if (field->get_tag("var") == "jids") + jids_field = field; + else if (field->get_tag("var") == "quit-message") + message_field = field; + if (message_field) + { + const XmlNode* value = message_field->get_child("value", "jabber:x:data"); + if (value) + quit_message = value->get_inner(); + } + if (jids_field) + { + std::size_t num = 0; + for (const XmlNode* value: jids_field->get_children("value", "jabber:x:data")) + { + Bridge* bridge = biboumi_component.find_user_bridge(value->get_inner()); + if (bridge) + { + bridge->shutdown(quit_message); + num++; + } + } + command_node.delete_all_children(); + + XmlNode note("note"); + note["type"] = "info"; + if (num == 0) + note.set_inner("No user were disconnected."); + else if (num == 1) + note.set_inner("1 user has been disconnected."); + else + note.set_inner(std::to_string(num) + " users have been disconnected."); + command_node.add_child(std::move(note)); + return; + } + } + XmlNode error(ADHOC_NS":error"); + error["type"] = "modify"; + XmlNode condition(STANZA_NS":bad-request"); + error.add_child(std::move(condition)); + command_node.add_child(std::move(error)); + session.terminate(); +} + +#ifdef USE_DATABASE +void ConfigureIrcServerStep1(XmppComponent&, AdhocSession& session, XmlNode& command_node) +{ + const Jid owner(session.get_owner_jid()); + const Jid target(session.get_target_jid()); + std::string server_domain; + if ((server_domain = Config::get("fixed_irc_server", "")).empty()) + server_domain = target.local; + auto options = Database::get_irc_server_options(owner.local + "@" + owner.domain, + server_domain); + + XmlNode x("jabber:x:data:x"); + x["type"] = "form"; + XmlNode title("title"); + title.set_inner("Configure the IRC server "s + server_domain); + x.add_child(std::move(title)); + XmlNode instructions("instructions"); + instructions.set_inner("Edit the form, to configure the settings of the IRC server "s + server_domain); + x.add_child(std::move(instructions)); + + XmlNode required("required"); + + XmlNode ports("field"); + ports["var"] = "ports"; + ports["type"] = "text-multi"; + ports["label"] = "Ports"; + ports["desc"] = "List of ports to try, without TLS. Defaults: 6667."; + auto vals = utils::split(options.ports.value(), ';', false); + for (const auto& val: vals) + { + XmlNode ports_value("value"); + ports_value.set_inner(val); + ports.add_child(std::move(ports_value)); + } + ports.add_child(required); + x.add_child(std::move(ports)); + +#ifdef BOTAN_FOUND + XmlNode tls_ports("field"); + tls_ports["var"] = "tls_ports"; + tls_ports["type"] = "text-multi"; + tls_ports["label"] = "TLS ports"; + tls_ports["desc"] = "List of ports to try, with TLS. Defaults: 6697, 6670."; + vals = utils::split(options.tlsPorts.value(), ';', false); + for (const auto& val: vals) + { + XmlNode tls_ports_value("value"); + tls_ports_value.set_inner(val); + tls_ports.add_child(std::move(tls_ports_value)); + } + tls_ports.add_child(required); + x.add_child(std::move(tls_ports)); + + XmlNode verify_cert("field"); + verify_cert["var"] = "verify_cert"; + verify_cert["type"] = "boolean"; + verify_cert["label"] = "Verify certificate"; + verify_cert["desc"] = "Whether or not to abort the connection if the server’s TLS certificate is invalid"; + XmlNode verify_cert_value("value"); + if (options.verifyCert.value()) + verify_cert_value.set_inner("true"); + else + verify_cert_value.set_inner("false"); + verify_cert.add_child(std::move(verify_cert_value)); + x.add_child(std::move(verify_cert)); + + XmlNode fingerprint("field"); + fingerprint["var"] = "fingerprint"; + fingerprint["type"] = "text-single"; + fingerprint["label"] = "SHA-1 fingerprint of the TLS certificate to trust."; + if (!options.trustedFingerprint.value().empty()) + { + XmlNode fingerprint_value("value"); + fingerprint_value.set_inner(options.trustedFingerprint.value()); + fingerprint.add_child(std::move(fingerprint_value)); + } + fingerprint.add_child(required); + x.add_child(std::move(fingerprint)); +#endif + + XmlNode pass("field"); + pass["var"] = "pass"; + pass["type"] = "text-private"; + pass["label"] = "Server password (to be used in a PASS command when connecting)"; + if (!options.pass.value().empty()) + { + XmlNode pass_value("value"); + pass_value.set_inner(options.pass.value()); + pass.add_child(std::move(pass_value)); + } + pass.add_child(required); + x.add_child(std::move(pass)); + + XmlNode after_cnt_cmd("field"); + after_cnt_cmd["var"] = "after_connect_command"; + after_cnt_cmd["type"] = "text-single"; + after_cnt_cmd["desc"] = "Custom IRC command sent after the connection is established with the server."; + after_cnt_cmd["label"] = "After-connection IRC command"; + if (!options.afterConnectionCommand.value().empty()) + { + XmlNode after_cnt_cmd_value("value"); + after_cnt_cmd_value.set_inner(options.afterConnectionCommand.value()); + after_cnt_cmd.add_child(std::move(after_cnt_cmd_value)); + } + after_cnt_cmd.add_child(required); + x.add_child(std::move(after_cnt_cmd)); + + if (Config::get("realname_customization", "true") == "true") + { + XmlNode username("field"); + username["var"] = "username"; + username["type"] = "text-single"; + username["label"] = "Username"; + if (!options.username.value().empty()) + { + XmlNode username_value("value"); + username_value.set_inner(options.username.value()); + username.add_child(std::move(username_value)); + } + username.add_child(required); + x.add_child(std::move(username)); + + XmlNode realname("field"); + realname["var"] = "realname"; + realname["type"] = "text-single"; + realname["label"] = "Realname"; + if (!options.realname.value().empty()) + { + XmlNode realname_value("value"); + realname_value.set_inner(options.realname.value()); + realname.add_child(std::move(realname_value)); + } + realname.add_child(required); + x.add_child(std::move(realname)); + } + + XmlNode encoding_out("field"); + encoding_out["var"] = "encoding_out"; + encoding_out["type"] = "text-single"; + encoding_out["desc"] = "The encoding used when sending messages to the IRC server."; + encoding_out["label"] = "Out encoding"; + if (!options.encodingOut.value().empty()) + { + XmlNode encoding_out_value("value"); + encoding_out_value.set_inner(options.encodingOut.value()); + encoding_out.add_child(std::move(encoding_out_value)); + } + encoding_out.add_child(required); + x.add_child(std::move(encoding_out)); + + XmlNode encoding_in("field"); + encoding_in["var"] = "encoding_in"; + encoding_in["type"] = "text-single"; + encoding_in["desc"] = "The encoding used to decode message received from the IRC server."; + encoding_in["label"] = "In encoding"; + if (!options.encodingIn.value().empty()) + { + XmlNode encoding_in_value("value"); + encoding_in_value.set_inner(options.encodingIn.value()); + encoding_in.add_child(std::move(encoding_in_value)); + } + encoding_in.add_child(required); + x.add_child(std::move(encoding_in)); + + + command_node.add_child(std::move(x)); +} + +void ConfigureIrcServerStep2(XmppComponent&, AdhocSession& session, XmlNode& command_node) +{ + const XmlNode* x = command_node.get_child("x", "jabber:x:data"); + if (x) + { + const Jid owner(session.get_owner_jid()); + const Jid target(session.get_target_jid()); + std::string server_domain; + if ((server_domain = Config::get("fixed_irc_server", "")).empty()) + server_domain = target.local; + auto options = Database::get_irc_server_options(owner.local + "@" + owner.domain, + server_domain); + for (const XmlNode* field: x->get_children("field", "jabber:x:data")) + { + const XmlNode* value = field->get_child("value", "jabber:x:data"); + const std::vector<const XmlNode*> values = field->get_children("value", "jabber:x:data"); + if (field->get_tag("var") == "ports") + { + std::string ports; + for (const auto& val: values) + ports += val->get_inner() + ";"; + options.ports = ports; + } + +#ifdef BOTAN_FOUND + else if (field->get_tag("var") == "tls_ports") + { + std::string ports; + for (const auto& val: values) + ports += val->get_inner() + ";"; + options.tlsPorts = ports; + } + + else if (field->get_tag("var") == "verify_cert" && value + && !value->get_inner().empty()) + { + auto val = to_bool(value->get_inner()); + options.verifyCert = val; + } + + else if (field->get_tag("var") == "fingerprint" && value && + !value->get_inner().empty()) + { + options.trustedFingerprint = value->get_inner(); + } + +#endif // BOTAN_FOUND + + else if (field->get_tag("var") == "pass" && + value && !value->get_inner().empty()) + options.pass = value->get_inner(); + + else if (field->get_tag("var") == "after_connect_command" && + value && !value->get_inner().empty()) + options.afterConnectionCommand = value->get_inner(); + + else if (field->get_tag("var") == "username" && + value && !value->get_inner().empty()) + { + auto username = value->get_inner(); + // The username must not contain spaces + std::replace(username.begin(), username.end(), ' ', '_'); + options.username = username; + } + + else if (field->get_tag("var") == "realname" && + value && !value->get_inner().empty()) + options.realname = value->get_inner(); + + else if (field->get_tag("var") == "encoding_out" && + value && !value->get_inner().empty()) + options.encodingOut = value->get_inner(); + + else if (field->get_tag("var") == "encoding_in" && + value && !value->get_inner().empty()) + options.encodingIn = value->get_inner(); + + } + + options.update(); + + command_node.delete_all_children(); + XmlNode note("note"); + note["type"] = "info"; + note.set_inner("Configuration successfully applied."); + command_node.add_child(std::move(note)); + return; + } + XmlNode error(ADHOC_NS":error"); + error["type"] = "modify"; + XmlNode condition(STANZA_NS":bad-request"); + error.add_child(std::move(condition)); + command_node.add_child(std::move(error)); + session.terminate(); +} + +void ConfigureIrcChannelStep1(XmppComponent&, AdhocSession& session, XmlNode& command_node) +{ + const Jid owner(session.get_owner_jid()); + const Jid target(session.get_target_jid()); + const Iid iid(target.local); + auto options = Database::get_irc_channel_options_with_server_default(owner.local + "@" + owner.domain, + iid.get_server(), iid.get_local()); + + XmlNode x("jabber:x:data:x"); + x["type"] = "form"; + XmlNode title("title"); + title.set_inner("Configure the IRC channel "s + iid.get_local() + " on server "s + iid.get_server()); + x.add_child(std::move(title)); + XmlNode instructions("instructions"); + instructions.set_inner("Edit the form, to configure the settings of the IRC channel "s + iid.get_local()); + x.add_child(std::move(instructions)); + + XmlNode required("required"); + + XmlNode encoding_out("field"); + encoding_out["var"] = "encoding_out"; + encoding_out["type"] = "text-single"; + encoding_out["desc"] = "The encoding used when sending messages to the IRC server. Defaults to the server's “out encoding” if unset for the channel"; + encoding_out["label"] = "Out encoding"; + if (!options.encodingOut.value().empty()) + { + XmlNode encoding_out_value("value"); + encoding_out_value.set_inner(options.encodingOut.value()); + encoding_out.add_child(std::move(encoding_out_value)); + } + encoding_out.add_child(required); + x.add_child(std::move(encoding_out)); + + XmlNode encoding_in("field"); + encoding_in["var"] = "encoding_in"; + encoding_in["type"] = "text-single"; + encoding_in["desc"] = "The encoding used to decode message received from the IRC server. Defaults to the server's “in encoding” if unset for the channel"; + encoding_in["label"] = "In encoding"; + if (!options.encodingIn.value().empty()) + { + XmlNode encoding_in_value("value"); + encoding_in_value.set_inner(options.encodingIn.value()); + encoding_in.add_child(std::move(encoding_in_value)); + } + encoding_in.add_child(required); + x.add_child(std::move(encoding_in)); + + command_node.add_child(std::move(x)); +} + +void ConfigureIrcChannelStep2(XmppComponent&, AdhocSession& session, XmlNode& command_node) +{ + const XmlNode* x = command_node.get_child("x", "jabber:x:data"); + if (x) + { + const Jid owner(session.get_owner_jid()); + const Jid target(session.get_target_jid()); + const Iid iid(target.local); + auto options = Database::get_irc_channel_options(owner.local + "@" + owner.domain, + iid.get_server(), iid.get_local()); + for (const XmlNode* field: x->get_children("field", "jabber:x:data")) + { + const XmlNode* value = field->get_child("value", "jabber:x:data"); + + if (field->get_tag("var") == "encoding_out" && + value && !value->get_inner().empty()) + options.encodingOut = value->get_inner(); + + else if (field->get_tag("var") == "encoding_in" && + value && !value->get_inner().empty()) + options.encodingIn = value->get_inner(); + } + + options.update(); + + command_node.delete_all_children(); + XmlNode note("note"); + note["type"] = "info"; + note.set_inner("Configuration successfully applied."); + command_node.add_child(std::move(note)); + return; + } + XmlNode error(ADHOC_NS":error"); + error["type"] = "modify"; + XmlNode condition(STANZA_NS":bad-request"); + error.add_child(std::move(condition)); + command_node.add_child(std::move(error)); + session.terminate(); +} +#endif // USE_DATABASE + +void DisconnectUserFromServerStep1(XmppComponent& xmpp_component, AdhocSession& session, XmlNode& command_node) +{ + const Jid owner(session.get_owner_jid()); + if (owner.bare() != Config::get("admin", "")) + { // A non-admin is not allowed to disconnect other users, only + // him/herself, so we just skip this step + auto next_step = session.get_next_step(); + next_step(xmpp_component, session, command_node); + } + else + { // Send a form to select the user to disconnect + auto& biboumi_component = static_cast<BiboumiComponent&>(xmpp_component); + + XmlNode x("jabber:x:data:x"); + x["type"] = "form"; + XmlNode title("title"); + title.set_inner("Disconnect a user from selected IRC servers"); + x.add_child(std::move(title)); + XmlNode instructions("instructions"); + instructions.set_inner("Choose a user JID"); + x.add_child(std::move(instructions)); + XmlNode jids_field("field"); + jids_field["var"] = "jid"; + jids_field["type"] = "list-single"; + jids_field["label"] = "The JID to disconnect"; + XmlNode required("required"); + jids_field.add_child(std::move(required)); + for (Bridge* bridge: biboumi_component.get_bridges()) + { + XmlNode option("option"); + option["label"] = bridge->get_jid(); + XmlNode value("value"); + value.set_inner(bridge->get_jid()); + option.add_child(std::move(value)); + jids_field.add_child(std::move(option)); + } + x.add_child(std::move(jids_field)); + command_node.add_child(std::move(x)); + } +} + +void DisconnectUserFromServerStep2(XmppComponent& xmpp_component, AdhocSession& session, XmlNode& command_node) +{ + // If no JID is contained in the command node, it means we skipped the + // previous stage, and the jid to disconnect is the executor's jid + std::string jid_to_disconnect = session.get_owner_jid(); + + if (const XmlNode* x = command_node.get_child("x", "jabber:x:data")) + { + for (const XmlNode* field: x->get_children("field", "jabber:x:data")) + if (field->get_tag("var") == "jid") + { + if (const XmlNode* value = field->get_child("value", "jabber:x:data")) + jid_to_disconnect = value->get_inner(); + } + } + + // Save that JID for the last step + session.vars["jid"] = jid_to_disconnect; + + // Send a data form to let the user choose which server to disconnect the + // user from + command_node.delete_all_children(); + auto& biboumi_component = static_cast<BiboumiComponent&>(xmpp_component); + + XmlNode x("jabber:x:data:x"); + x["type"] = "form"; + XmlNode title("title"); + title.set_inner("Disconnect a user from selected IRC servers"); + x.add_child(std::move(title)); + XmlNode instructions("instructions"); + instructions.set_inner("Choose one or more servers to disconnect this JID from"); + x.add_child(std::move(instructions)); + XmlNode jids_field("field"); + jids_field["var"] = "irc-servers"; + jids_field["type"] = "list-multi"; + jids_field["label"] = "The servers to disconnect from"; + XmlNode required("required"); + jids_field.add_child(std::move(required)); + Bridge* bridge = biboumi_component.find_user_bridge(jid_to_disconnect); + + if (!bridge || bridge->get_irc_clients().empty()) + { + XmlNode note("note"); + note["type"] = "info"; + note.set_inner("User "s + jid_to_disconnect + " is not connected to any IRC server."); + command_node.add_child(std::move(note)); + session.terminate(); + return ; + } + + for (const auto& pair: bridge->get_irc_clients()) + { + XmlNode option("option"); + option["label"] = pair.first; + XmlNode value("value"); + value.set_inner(pair.first); + option.add_child(std::move(value)); + jids_field.add_child(std::move(option)); + } + x.add_child(std::move(jids_field)); + + XmlNode message_field("field"); + message_field["var"] = "quit-message"; + message_field["type"] = "text-single"; + message_field["label"] = "Quit message"; + XmlNode message_value("value"); + message_value.set_inner("Killed by admin"); + message_field.add_child(std::move(message_value)); + x.add_child(std::move(message_field)); + + command_node.add_child(std::move(x)); +} + +void DisconnectUserFromServerStep3(XmppComponent& xmpp_component, AdhocSession& session, XmlNode& command_node) +{ + const auto it = session.vars.find("jid"); + if (it == session.vars.end()) + return ; + const auto jid_to_disconnect = it->second; + + std::vector<std::string> servers; + std::string quit_message; + + if (const XmlNode* x = command_node.get_child("x", "jabber:x:data")) + { + for (const XmlNode* field: x->get_children("field", "jabber:x:data")) + { + if (field->get_tag("var") == "irc-servers") + { + for (const XmlNode* value: field->get_children("value", "jabber:x:data")) + servers.push_back(value->get_inner()); + } + else if (field->get_tag("var") == "quit-message") + if (const XmlNode* value = field->get_child("value", "jabber:x:data")) + quit_message = value->get_inner(); + } + } + + auto& biboumi_component = static_cast<BiboumiComponent&>(xmpp_component); + Bridge* bridge = biboumi_component.find_user_bridge(jid_to_disconnect); + auto& clients = bridge->get_irc_clients(); + + std::size_t number = 0; + + for (const auto& hostname: servers) + { + auto it = clients.find(hostname); + if (it != clients.end()) + { + it->second->on_error({"ERROR", {quit_message}}); + clients.erase(it); + number++; + } + } + command_node.delete_all_children(); + XmlNode note("note"); + note["type"] = "info"; + std::string msg = jid_to_disconnect + " was disconnected from " + std::to_string(number) + " IRC server"; + if (number > 1) + msg += "s"; + msg += "."; + note.set_inner(msg); + command_node.add_child(std::move(note)); +} diff --git a/src/xmpp/biboumi_adhoc_commands.hpp b/src/xmpp/biboumi_adhoc_commands.hpp new file mode 100644 index 0000000..2763a9f --- /dev/null +++ b/src/xmpp/biboumi_adhoc_commands.hpp @@ -0,0 +1,23 @@ +#pragma once + + +#include <xmpp/adhoc_command.hpp> +#include <xmpp/adhoc_session.hpp> +#include <xmpp/xmpp_stanza.hpp> + +class XmppComponent; + +void DisconnectUserStep1(XmppComponent&, AdhocSession& session, XmlNode& command_node); +void DisconnectUserStep2(XmppComponent&, AdhocSession& session, XmlNode& command_node); + +void ConfigureIrcServerStep1(XmppComponent&, AdhocSession& session, XmlNode& command_node); +void ConfigureIrcServerStep2(XmppComponent&, AdhocSession& session, XmlNode& command_node); + +void ConfigureIrcChannelStep1(XmppComponent&, AdhocSession& session, XmlNode& command_node); +void ConfigureIrcChannelStep2(XmppComponent&, AdhocSession& session, XmlNode& command_node); + +void DisconnectUserFromServerStep1(XmppComponent&, AdhocSession& session, XmlNode& command_node); +void DisconnectUserFromServerStep2(XmppComponent&, AdhocSession& session, XmlNode& command_node); +void DisconnectUserFromServerStep3(XmppComponent&, AdhocSession& session, XmlNode& command_node); + + diff --git a/src/xmpp/biboumi_component.cpp b/src/xmpp/biboumi_component.cpp new file mode 100644 index 0000000..a6aac21 --- /dev/null +++ b/src/xmpp/biboumi_component.cpp @@ -0,0 +1,632 @@ +#include <xmpp/biboumi_component.hpp> + +#include <utils/timed_events.hpp> +#include <utils/scopeguard.hpp> +#include <utils/tolower.hpp> +#include <logger/logger.hpp> +#include <xmpp/adhoc_command.hpp> +#include <xmpp/biboumi_adhoc_commands.hpp> +#include <bridge/list_element.hpp> +#include <config/config.hpp> +#include <xmpp/jid.hpp> +#include <utils/sha1.hpp> + +#include <stdexcept> +#include <iostream> + +#include <stdio.h> + +#include <louloulibs.h> +#include <biboumi.h> + +#include <uuid.h> + +#ifdef SYSTEMD_FOUND +# include <systemd/sd-daemon.h> +#endif + +using namespace std::string_literals; + +static std::set<std::string> kickable_errors{ + "gone", + "internal-server-error", + "item-not-found", + "jid-malformed", + "recipient-unavailable", + "redirect", + "remote-server-not-found", + "remote-server-timeout", + "service-unavailable", + "malformed-error" + }; + + +BiboumiComponent::BiboumiComponent(std::shared_ptr<Poller> poller, const std::string& hostname, const std::string& secret): + XmppComponent(poller, hostname, secret), + irc_server_adhoc_commands_handler(*this), + irc_channel_adhoc_commands_handler(*this) +{ + this->stanza_handlers.emplace("presence", + std::bind(&BiboumiComponent::handle_presence, this,std::placeholders::_1)); + this->stanza_handlers.emplace("message", + std::bind(&BiboumiComponent::handle_message, this,std::placeholders::_1)); + this->stanza_handlers.emplace("iq", + std::bind(&BiboumiComponent::handle_iq, this,std::placeholders::_1)); + + this->adhoc_commands_handler.get_commands() = { + {"ping", AdhocCommand({&PingStep1}, "Do a ping", false)}, + {"hello", AdhocCommand({&HelloStep1, &HelloStep2}, "Receive a custom greeting", false)}, + {"disconnect-user", AdhocCommand({&DisconnectUserStep1, &DisconnectUserStep2}, "Disconnect selected users from the gateway", true)}, + {"disconnect-from-irc-servers", AdhocCommand({&DisconnectUserFromServerStep1, &DisconnectUserFromServerStep2, &DisconnectUserFromServerStep3}, "Disconnect from the selected IRC servers", false)}, + {"reload", AdhocCommand({&Reload}, "Reload biboumi’s configuration", true)} + }; + +#ifdef USE_DATABASE + AdhocCommand configure_server_command({&ConfigureIrcServerStep1, &ConfigureIrcServerStep2}, "Configure a few settings for that IRC server", false); + if (!Config::get("fixed_irc_server", "").empty()) + { + this->adhoc_commands_handler.get_commands().emplace(std::make_pair("configure", + configure_server_command)); + } +#endif + + this->irc_server_adhoc_commands_handler.get_commands() = { +#ifdef USE_DATABASE + {"configure", configure_server_command}, +#endif + }; + this->irc_channel_adhoc_commands_handler.get_commands() = { +#ifdef USE_DATABASE + {"configure", AdhocCommand({&ConfigureIrcChannelStep1, &ConfigureIrcChannelStep2}, "Configure a few settings for that IRC channel", false)}, +#endif + }; +} + +void BiboumiComponent::shutdown() +{ + for (auto it = this->bridges.begin(); it != this->bridges.end(); ++it) + { + it->second->shutdown("Gateway shutdown"); + } +} + +void BiboumiComponent::clean() +{ + auto it = this->bridges.begin(); + while (it != this->bridges.end()) + { + it->second->clean(); + if (it->second->active_clients() == 0) + it = this->bridges.erase(it); + else + ++it; + } +} + +void BiboumiComponent::handle_presence(const Stanza& stanza) +{ + std::string from_str = stanza.get_tag("from"); + std::string id = stanza.get_tag("id"); + std::string to_str = stanza.get_tag("to"); + std::string type = stanza.get_tag("type"); + + // Check for mandatory tags + if (from_str.empty()) + { + log_warning("Received an invalid presence stanza: tag 'from' is missing."); + return; + } + if (to_str.empty()) + { + this->send_stanza_error("presence", from_str, this->served_hostname, id, + "modify", "bad-request", "Missing 'to' tag"); + return; + } + + Bridge* bridge = this->get_user_bridge(from_str); + Jid to(to_str); + Jid from(from_str); + Iid iid(to.local); + + // An error stanza is sent whenever we exit this function without + // disabling this scopeguard. If error_type and error_name are not + // changed, the error signaled is internal-server-error. Change their + // value to signal an other kind of error. For example + // feature-not-implemented, etc. Any non-error process should reach the + // stanza_error.disable() call at the end of the function. + std::string error_type("cancel"); + std::string error_name("internal-server-error"); + utils::ScopeGuard stanza_error([&](){ + this->send_stanza_error("presence", from_str, to_str, id, + error_type, error_name, ""); + }); + + try { + if (iid.is_channel && !iid.get_server().empty()) + { // presence toward a MUC that corresponds to an irc channel, or a + // dummy channel if iid.chan is empty + if (type.empty()) + { + const std::string own_nick = bridge->get_own_nick(iid); + if (!own_nick.empty() && own_nick != to.resource) + bridge->send_irc_nick_change(iid, to.resource); + const XmlNode* x = stanza.get_child("x", MUC_NS); + const XmlNode* password = x ? x->get_child("password", MUC_NS): nullptr; + bridge->join_irc_channel(iid, to.resource, password ? password->get_inner(): "", + from.resource); + } + else if (type == "unavailable") + { + const XmlNode* status = stanza.get_child("status", COMPONENT_NS); + bridge->leave_irc_channel(std::move(iid), status ? status->get_inner() : "", from.resource); + } + } + else + { + // An user wants to join an invalid IRC channel, return a presence error to him + if (type.empty()) + this->send_invalid_room_error(to.local, to.resource, from_str); + } + } + catch (const IRCNotConnected& ex) + { + this->send_stanza_error("presence", from_str, to_str, id, + "cancel", "remote-server-not-found", + "Not connected to IRC server "s + ex.hostname, + true); + } + stanza_error.disable(); +} + +void BiboumiComponent::handle_message(const Stanza& stanza) +{ + std::string from = stanza.get_tag("from"); + std::string id = stanza.get_tag("id"); + std::string to_str = stanza.get_tag("to"); + std::string type = stanza.get_tag("type"); + + if (from.empty()) + return; + if (type.empty()) + type = "normal"; + Bridge* bridge = this->get_user_bridge(from); + Jid to(to_str); + Iid iid(to.local); + + std::string error_type("cancel"); + std::string error_name("internal-server-error"); + utils::ScopeGuard stanza_error([&](){ + this->send_stanza_error("message", from, to_str, id, + error_type, error_name, ""); + }); + const XmlNode* body = stanza.get_child("body", COMPONENT_NS); + + try { // catch IRCNotConnected exceptions + if (type == "groupchat" && iid.is_channel) + { + if (body && !body->get_inner().empty()) + { + bridge->send_channel_message(iid, body->get_inner()); + } + const XmlNode* subject = stanza.get_child("subject", COMPONENT_NS); + if (subject) + bridge->set_channel_topic(iid, subject->get_inner()); + } + else if (type == "error") + { + const XmlNode* error = stanza.get_child("error", COMPONENT_NS); + // Only a set of errors are considered “fatal”. If we encounter one of + // them, we purge (we disconnect the user from all the IRC servers). + // We consider this to be true, unless the error condition is + // specified and is not in the kickable_errors set + bool kickable_error = true; + if (error && error->has_children()) + { + const XmlNode* condition = error->get_last_child(); + if (kickable_errors.find(condition->get_name()) == kickable_errors.end()) + kickable_error = false; + } + if (kickable_error) + bridge->shutdown("Error from remote client"); + } + else if (type == "chat") + { + if (body && !body->get_inner().empty()) + { + // a message for nick!server + if (iid.is_user && !iid.get_local().empty()) + { + bridge->send_private_message(iid, body->get_inner()); + bridge->remove_preferred_from_jid(iid.get_local()); + } + else if (!iid.is_user && !to.resource.empty()) + { // a message for chan%server@biboumi/Nick or + // server@biboumi/Nick + // Convert that into a message to nick!server + Iid user_iid(utils::tolower(to.resource) + "!" + iid.get_server()); + bridge->send_private_message(user_iid, body->get_inner()); + bridge->set_preferred_from_jid(user_iid.get_local(), to_str); + } + else if (!iid.is_user && !iid.is_channel) + { // Message sent to the server JID + // Convert the message body into a raw IRC message + bridge->send_raw_message(iid.get_server(), body->get_inner()); + } + } + } + else if (iid.is_user) + this->send_invalid_user_error(to.local, from); + } catch (const IRCNotConnected& ex) + { + this->send_stanza_error("message", from, to_str, id, + "cancel", "remote-server-not-found", + "Not connected to IRC server "s + ex.hostname, + true); + } + stanza_error.disable(); +} + +// We MUST return an iq, whatever happens, except if the type is +// "result". +// To do this, we use a scopeguard. If an exception is raised somewhere, an +// iq of type error "internal-server-error" is sent. If we handle the +// request properly (by calling a function that registers an iq to be sent +// later, or that directly sends an iq), we disable the ScopeGuard. If we +// reach the end of the function without having disabled the scopeguard, we +// send a "feature-not-implemented" iq as a result. If an other kind of +// error is found (for example the feature is implemented in biboumi, but +// the request is missing some attribute) we can just change the values of +// error_type and error_name and return from the function (without disabling +// the scopeguard); an iq error will be sent +void BiboumiComponent::handle_iq(const Stanza& stanza) +{ + std::string id = stanza.get_tag("id"); + std::string from = stanza.get_tag("from"); + std::string to_str = stanza.get_tag("to"); + std::string type = stanza.get_tag("type"); + + if (from.empty()) { + log_warning("Received an iq without a 'from'. Ignoring."); + return; + } + if (id.empty() || to_str.empty() || type.empty()) + { + this->send_stanza_error("iq", from, this->served_hostname, id, + "modify", "bad-request", ""); + return; + } + + Bridge* bridge = this->get_user_bridge(from); + Jid to(to_str); + + // These two values will be used in the error iq sent if we don't disable + // the scopeguard. + std::string error_type("cancel"); + std::string error_name("internal-server-error"); + utils::ScopeGuard stanza_error([&](){ + this->send_stanza_error("iq", from, to_str, id, + error_type, error_name, ""); + }); + try { + if (type == "set") + { + const XmlNode* query; + if ((query = stanza.get_child("query", MUC_ADMIN_NS))) + { + const XmlNode* child = query->get_child("item", MUC_ADMIN_NS); + if (child) + { + std::string nick = child->get_tag("nick"); + std::string role = child->get_tag("role"); + std::string affiliation = child->get_tag("affiliation"); + if (!nick.empty()) + { + Iid iid(to.local); + if (role == "none") + { // This is a kick + std::string reason; + const XmlNode* reason_el = child->get_child("reason", MUC_ADMIN_NS); + if (reason_el) + reason = reason_el->get_inner(); + bridge->send_irc_kick(iid, nick, reason, id, from); + } + else + bridge->forward_affiliation_role_change(iid, nick, affiliation, role); + stanza_error.disable(); + } + } + } + else if ((query = stanza.get_child("command", ADHOC_NS))) + { + Stanza response("iq"); + response["to"] = from; + response["from"] = to_str; + response["id"] = id; + + // Depending on the 'to' jid in the request, we use one adhoc + // command handler or an other + Iid iid(to.local); + AdhocCommandsHandler* adhoc_handler; + if (!to.local.empty() && !iid.is_user && !iid.is_channel) + adhoc_handler = &this->irc_server_adhoc_commands_handler; + else if (!to.local.empty() && iid.is_channel) + adhoc_handler = &this->irc_channel_adhoc_commands_handler; + else + adhoc_handler = &this->adhoc_commands_handler; + + // Execute the command, if any, and get a result XmlNode that we + // insert in our response + XmlNode inner_node = adhoc_handler->handle_request(from, to_str, *query); + if (inner_node.get_child("error", ADHOC_NS)) + response["type"] = "error"; + else + response["type"] = "result"; + response.add_child(std::move(inner_node)); + this->send_stanza(response); + stanza_error.disable(); + } + } + else if (type == "get") + { + const XmlNode* query; + if ((query = stanza.get_child("query", DISCO_INFO_NS))) + { // Disco info + if (to_str == this->served_hostname) + { + const std::string node = query->get_tag("node"); + if (node.empty()) + { + // On the gateway itself + this->send_self_disco_info(id, from); + stanza_error.disable(); + } + } + } + else if ((query = stanza.get_child("query", VERSION_NS))) + { + Iid iid(to.local); + if (iid.is_user || + (iid.is_channel && !to.resource.empty())) + { + // Get the IRC user version + std::string target; + if (iid.is_user) + target = iid.get_local(); + else + target = to.resource; + bridge->send_irc_version_request(iid.get_server(), target, id, + from, to_str); + } + else + { + // On the gateway itself or on a channel + this->send_version(id, from, to_str); + } + stanza_error.disable(); + } + else if ((query = stanza.get_child("query", DISCO_ITEMS_NS))) + { + Iid iid(to.local); + const std::string node = query->get_tag("node"); + if (node == ADHOC_NS) + { + Jid from_jid(from); + if (to.local.empty()) + { // Get biboumi's adhoc commands + this->send_adhoc_commands_list(id, from, this->served_hostname, + (Config::get("admin", "") == + from_jid.bare()), + this->adhoc_commands_handler); + stanza_error.disable(); + } + else if (!iid.is_user && !iid.is_channel) + { // Get the server's adhoc commands + this->send_adhoc_commands_list(id, from, to_str, + (Config::get("admin", "") == + from_jid.bare()), + this->irc_server_adhoc_commands_handler); + stanza_error.disable(); + } + else if (!iid.is_user && iid.is_channel) + { // Get the channel's adhoc commands + this->send_adhoc_commands_list(id, from, to_str, + (Config::get("admin", "") == + from_jid.bare()), + this->irc_channel_adhoc_commands_handler); + stanza_error.disable(); + } + } + else if (node.empty() && !iid.is_user && !iid.is_channel) + { // Disco on an IRC server: get the list of channels + bridge->send_irc_channel_list_request(iid, id, from); + stanza_error.disable(); + } + } + else if ((query = stanza.get_child("ping", PING_NS))) + { + Iid iid(to.local); + if (iid.is_user) + { // Ping any user (no check on the nick done ourself) + bridge->send_irc_user_ping_request(iid.get_server(), + iid.get_local(), id, from, to_str); + } + else if (iid.is_channel && !to.resource.empty()) + { // Ping a room participant (we check if the nick is in the room) + bridge->send_irc_participant_ping_request(iid, + to.resource, id, from, to_str); + } + else + { // Ping a channel, a server or the gateway itself + bridge->on_gateway_ping(iid.get_server(), + id, from, to_str); + } + stanza_error.disable(); + } + } + else if (type == "result") + { + stanza_error.disable(); + const XmlNode* query; + if ((query = stanza.get_child("query", VERSION_NS))) + { + const XmlNode* name_node = query->get_child("name", VERSION_NS); + const XmlNode* version_node = query->get_child("version", VERSION_NS); + const XmlNode* os_node = query->get_child("os", VERSION_NS); + std::string name; + std::string version; + std::string os; + if (name_node) + name = name_node->get_inner() + " (through the biboumi gateway)"; + if (version_node) + version = version_node->get_inner(); + if (os_node) + os = os_node->get_inner(); + const Iid iid(to.local); + bridge->send_xmpp_version_to_irc(iid, name, version, os); + } + else + { + const auto it = this->waiting_iq.find(id); + if (it != this->waiting_iq.end()) + { + it->second(bridge, stanza); + this->waiting_iq.erase(it); + } + } + } + } + catch (const IRCNotConnected& ex) + { + this->send_stanza_error("iq", from, to_str, id, + "cancel", "remote-server-not-found", + "Not connected to IRC server "s + ex.hostname, + true); + stanza_error.disable(); + return; + } + error_type = "cancel"; + error_name = "feature-not-implemented"; +} + +Bridge* BiboumiComponent::get_user_bridge(const std::string& user_jid) +{ + auto bare_jid = Jid{user_jid}.bare(); + try + { + return this->bridges.at(bare_jid).get(); + } + catch (const std::out_of_range& exception) + { + this->bridges.emplace(bare_jid, std::make_unique<Bridge>(bare_jid, *this, this->poller)); + return this->bridges.at(bare_jid).get(); + } +} + +Bridge* BiboumiComponent::find_user_bridge(const std::string& full_jid) +{ + auto bare_jid = Jid{full_jid}.bare(); + try + { + return this->bridges.at(bare_jid).get(); + } + catch (const std::out_of_range& exception) + { + return nullptr; + } +} + +std::vector<Bridge*> BiboumiComponent::get_bridges() const +{ + std::vector<Bridge*> res; + for (auto it = this->bridges.begin(); it != this->bridges.end(); ++it) + res.push_back(it->second.get()); + return res; +} + +void BiboumiComponent::send_self_disco_info(const std::string& id, const std::string& jid_to) +{ + Stanza iq("iq"); + iq["type"] = "result"; + iq["id"] = id; + iq["to"] = jid_to; + iq["from"] = this->served_hostname; + XmlNode query("query"); + query["xmlns"] = DISCO_INFO_NS; + XmlNode identity("identity"); + identity["category"] = "conference"; + identity["type"] = "irc"; + identity["name"] = "Biboumi XMPP-IRC gateway"; + query.add_child(std::move(identity)); + for (const char* ns: {DISCO_INFO_NS, MUC_NS, ADHOC_NS}) + { + XmlNode feature("feature"); + feature["var"] = ns; + query.add_child(std::move(feature)); + } + iq.add_child(std::move(query)); + this->send_stanza(iq); +} + +void BiboumiComponent::send_iq_version_request(const std::string& from, + const std::string& jid_to) +{ + Stanza iq("iq"); + iq["type"] = "get"; + iq["id"] = "version_"s + this->next_id(); + iq["from"] = from + "@" + this->served_hostname; + iq["to"] = jid_to; + XmlNode query("query"); + query["xmlns"] = VERSION_NS; + iq.add_child(std::move(query)); + this->send_stanza(iq); +} + +void BiboumiComponent::send_ping_request(const std::string& from, + const std::string& jid_to, + const std::string& id) +{ + Stanza iq("iq"); + iq["type"] = "get"; + iq["id"] = id; + iq["from"] = from + "@" + this->served_hostname; + iq["to"] = jid_to; + XmlNode ping("ping"); + ping["xmlns"] = PING_NS; + iq.add_child(std::move(ping)); + this->send_stanza(iq); + + auto result_cb = [from, id](Bridge* bridge, const Stanza& stanza) + { + Jid to(stanza.get_tag("to")); + if (to.local != from) + { + log_error("Received a corresponding ping result, but the 'to' from " + "the response mismatches the 'from' of the request"); + } + else + bridge->send_irc_ping_result(from, id); + }; + this->waiting_iq[id] = result_cb; +} + +void BiboumiComponent::send_iq_room_list_result(const std::string& id, + const std::string& to_jid, + const std::string& from, + const std::vector<ListElement>& rooms_list) +{ + Stanza iq("iq"); + iq["from"] = from + "@" + this->served_hostname; + iq["to"] = to_jid; + iq["id"] = id; + iq["type"] = "result"; + XmlNode query("query"); + query["xmlns"] = DISCO_ITEMS_NS; + for (const auto& room: rooms_list) + { + XmlNode item("item"); + item["jid"] = room.channel + "%" + from + "@" + this->served_hostname; + query.add_child(std::move(item)); + } + iq.add_child(std::move(query)); + this->send_stanza(iq); +} diff --git a/src/xmpp/biboumi_component.hpp b/src/xmpp/biboumi_component.hpp new file mode 100644 index 0000000..24d768a --- /dev/null +++ b/src/xmpp/biboumi_component.hpp @@ -0,0 +1,109 @@ +#pragma once + + +#include <xmpp/xmpp_component.hpp> + +#include <bridge/bridge.hpp> + +#include <memory> +#include <string> +#include <map> + +struct ListElement; + +/** + * A callback called when the waited iq result is received (it is matched + * against the iq id) + */ +using iq_responder_callback_t = std::function<void(Bridge* bridge, const Stanza& stanza)>; + +/** + * Interact with the Biboumi Bridge + */ +class BiboumiComponent: public XmppComponent +{ +public: + explicit BiboumiComponent(std::shared_ptr<Poller> poller, const std::string& hostname, const std::string& secret); + ~BiboumiComponent() = default; + + BiboumiComponent(const BiboumiComponent&) = delete; + BiboumiComponent(BiboumiComponent&&) = delete; + BiboumiComponent& operator=(const BiboumiComponent&) = delete; + BiboumiComponent& operator=(BiboumiComponent&&) = delete; + + /** + * Returns the bridge for the given user. If it does not exist, return + * nullptr. + */ + Bridge* find_user_bridge(const std::string& full_jid); + /** + * Return a list of all the managed bridges. + */ + std::vector<Bridge*> get_bridges() const; + + /** + * Send a "close" message to all our connected peers. That message + * depends on the protocol used (this may be a QUIT irc message, or a + * <stream/>, etc). We may also directly close the connection, or we may + * wait for the remote peer to acknowledge it before closing. + */ + void shutdown(); + /** + * Run a check on all bridges, to remove all disconnected (socket is + * closed, or no channel is joined) IrcClients. Some kind of garbage collector. + */ + void clean(); + /** + * Send a result IQ with the gateway disco informations. + */ + void send_self_disco_info(const std::string& id, const std::string& jid_to); + /** + * Send an iq version request + */ + void send_iq_version_request(const std::string& from, + const std::string& jid_to); + /** + * Send a ping request + */ + void send_ping_request(const std::string& from, + const std::string& jid_to, + const std::string& id); + /** + * Send the channels list in one big stanza + */ + void send_iq_room_list_result(const std::string& id, const std::string& to_jid, + const std::string& from, + const std::vector<ListElement>& rooms_list); + /** + * Handle the various stanza types + */ + void handle_presence(const Stanza& stanza); + void handle_message(const Stanza& stanza); + void handle_iq(const Stanza& stanza); + +private: + /** + * Return the bridge associated with the bare JID. Create a new one + * if none already exist. + */ + Bridge* get_user_bridge(const std::string& user_jid); + + /** + * A map of id -> callback. When we want to wait for an iq result, we add + * the callback to this map, with the iq id as the key. When an iq result + * is received, we look for a corresponding callback in this map. If + * found, we call it and remove it. + */ + std::map<std::string, iq_responder_callback_t> waiting_iq; + + /** + * One bridge for each user of the component. Indexed by the user's bare + * jid + */ + std::unordered_map<std::string, std::unique_ptr<Bridge>> bridges; + + AdhocCommandsHandler irc_server_adhoc_commands_handler; + AdhocCommandsHandler irc_channel_adhoc_commands_handler; +}; + + diff --git a/tests/colors.cpp b/tests/colors.cpp new file mode 100644 index 0000000..bf52989 --- /dev/null +++ b/tests/colors.cpp @@ -0,0 +1,54 @@ +#include "catch.hpp" + +#include <bridge/colors.hpp> +#include <xmpp/xmpp_stanza.hpp> + +#include <memory> + +TEST_CASE("IRC colors parsing") +{ + std::unique_ptr<XmlNode> xhtml; + std::string cleaned_up; + + std::tie(cleaned_up, xhtml) = irc_format_to_xhtmlim("bold"); + CHECK(xhtml); + CHECK(xhtml->to_string() == "<body xmlns='http://www.w3.org/1999/xhtml'><span style='font-weight:bold;'>bold</span></body>"); + + std::tie(cleaned_up, xhtml) = + irc_format_to_xhtmlim("normalboldunder-and-boldbold normal" + "5red,5default-on-red10,2cyan-on-blue"); + CHECK(xhtml); + CHECK(xhtml->to_string() == "<body xmlns='http://www.w3.org/1999/xhtml'>normal<span style='font-weight:bold;'>bold</span><span style='font-weight:bold;text-decoration:underline;'>under-and-bold</span><span style='font-weight:bold;'>bold</span> normal<span style='color:red;'>red</span><span style='background-color:red;'>default-on-red</span><span style='color:cyan;background-color:blue;'>cyan-on-blue</span></body>"); + CHECK(cleaned_up == "normalboldunder-and-boldbold normalreddefault-on-redcyan-on-blue"); + + std::tie(cleaned_up, xhtml) = irc_format_to_xhtmlim("normal"); + CHECK_FALSE(xhtml); + CHECK(cleaned_up == "normal"); + + std::tie(cleaned_up, xhtml) = irc_format_to_xhtmlim(""); + CHECK(xhtml); + CHECK(!xhtml->has_children()); + CHECK(cleaned_up.empty()); + + std::tie(cleaned_up, xhtml) = irc_format_to_xhtmlim(",a"); + CHECK(xhtml); + CHECK(!xhtml->has_children()); + CHECK(cleaned_up == "a"); + + std::tie(cleaned_up, xhtml) = irc_format_to_xhtmlim(","); + CHECK(xhtml); + CHECK(!xhtml->has_children()); + CHECK(cleaned_up.empty()); + + std::tie(cleaned_up, xhtml) = irc_format_to_xhtmlim("[\x1D13dolphin-emu/dolphin\x1D] 03foo commented on #283 (Add support for the guide button to XInput): 02http://example.com"); + CHECK(xhtml->to_string() == "<body xmlns='http://www.w3.org/1999/xhtml'>[<span style='font-style:italic;'/><span style='font-style:italic;color:lightmagenta;'>dolphin-emu/dolphin</span><span style='color:lightmagenta;'>] </span><span style='color:green;'>foo</span> commented on #283 (Add support for the guide button to XInput): <span style='text-decoration:underline;'/><span style='text-decoration:underline;color:blue;'>http://example.com</span><span style='text-decoration:underline;'/></body>"); + CHECK(cleaned_up == "[dolphin-emu/dolphin] foo commented on #283 (Add support for the guide button to XInput): http://example.com"); + + std::tie(cleaned_up, xhtml) = irc_format_to_xhtmlim("0e46ab by 03Pierre Dindon [090|091|040] 02http://example.net/Ojrh4P media: avoid pop-in effect when loading thumbnails by specifying an explicit size"); + CHECK(cleaned_up == "0e46ab by Pierre Dindon [0|1|0] http://example.net/Ojrh4P media: avoid pop-in effect when loading thumbnails by specifying an explicit size"); + CHECK(xhtml->to_string() == "<body xmlns='http://www.w3.org/1999/xhtml'>0e46ab by <span style='color:green;'>Pierre Dindon</span> [<span style='color:lightgreen;'>0</span>|<span style='color:lightgreen;'>1</span>|<span style='color:indianred;'>0</span>] <span style='text-decoration:underline;'/><span style='text-decoration:underline;color:blue;'>http://example.net/Ojrh4P</span><span style='text-decoration:underline;'/> media: avoid pop-in effect when loading thumbnails by specifying an explicit size</body>"); + + std::tie(cleaned_up, xhtml) = irc_format_to_xhtmlim("test\ncoucou"); + CHECK(cleaned_up == "test\ncoucou"); + CHECK(xhtml->to_string() == "<body xmlns='http://www.w3.org/1999/xhtml'>test<br/>coucou</body>"); +} diff --git a/tests/config.cpp b/tests/config.cpp new file mode 100644 index 0000000..ddea151 --- /dev/null +++ b/tests/config.cpp @@ -0,0 +1,54 @@ +#include "catch.hpp" + +#include <config/config.hpp> + +TEST_CASE("Config basic") +{ + // Write a value in the config file + Config::read_conf("test.cfg"); + Config::set("coucou", "bonjour", true); + Config::clear(); + + bool error = false; + try + { + CHECK(Config::read_conf()); + CHECK(Config::get("coucou", "") == "bonjour"); + CHECK(Config::get("does not exist", "default") == "default"); + Config::clear(); + } + catch (const std::ios::failure& e) + { + error = true; + } + CHECK_FALSE(error); +} + +TEST_CASE("Config callbacks") +{ + bool switched = false; + Config::connect([&switched]() + { + switched = !switched; + }); + CHECK_FALSE(switched); + Config::set("un", "deux", true); + CHECK(switched); + Config::set("un", "trois", true); + CHECK_FALSE(switched); + + Config::set("un", "trois", false); + CHECK_FALSE(switched); +} + +TEST_CASE("Config get_int") +{ + auto res = Config::get_int("number", 0); + CHECK(res == 0); + Config::set("number", "88"); + res = Config::get_int("number", 0); + CHECK(res == 88); + Config::set("number", "pouet"); + res = Config::get_int("number", -1); + CHECK(res == 0); +} diff --git a/tests/database.cpp b/tests/database.cpp new file mode 100644 index 0000000..4e2be14 --- /dev/null +++ b/tests/database.cpp @@ -0,0 +1,97 @@ +#include "catch.hpp" + +#include <database/database.hpp> + +#include <config/config.hpp> + +TEST_CASE("Database") +{ +#ifdef USE_DATABASE + Database::open(":memory:"); + Database::set_verbose(false); + + SECTION("Basic retrieve and update") + { + auto o = Database::get_irc_server_options("zouzou@example.com", "irc.example.com"); + o.update(); + auto a = Database::get_irc_server_options("zouzou@example.com", "irc.example.com"); + auto b = Database::get_irc_server_options("moumou@example.com", "irc.example.com"); + + // b does not yet exist in the db, the object is created but not yet + // inserted + CHECK(1 == Database::count<db::IrcServerOptions>()); + + b.update(); + CHECK(2 == Database::count<db::IrcServerOptions>()); + + CHECK(b.pass == ""); + CHECK(b.pass.value() == ""); + } + + SECTION("channel options") + { + Config::set("db_name", ":memory:"); + auto o = Database::get_irc_channel_options("zouzou@example.com", "irc.example.com", "#foo"); + + CHECK(o.encodingIn == ""); + o.encodingIn = "ISO-8859-1"; + o.update(); + auto b = Database::get_irc_channel_options("zouzou@example.com", "irc.example.com", "#foo"); + CHECK(o.encodingIn == "ISO-8859-1"); + } + + SECTION("Channel options with server default") + { + const std::string owner{"zouzou@example.com"}; + const std::string server{"irc.example.com"}; + const std::string chan1{"#foo"}; + + auto c = Database::get_irc_channel_options(owner, server, chan1); + auto s = Database::get_irc_server_options(owner, server); + + GIVEN("An option defined for the channel but not the server") + { + c.encodingIn = "channelEncoding"; + c.update(); + WHEN("we fetch that option") + { + auto r = Database::get_irc_channel_options_with_server_default(owner, server, chan1); + THEN("we get the channel option") + CHECK(r.encodingIn == "channelEncoding"); + } + } + GIVEN("An option defined for the server but not the channel") + { + s.encodingIn = "serverEncoding"; + s.update(); + WHEN("we fetch that option") + { + auto r = Database::get_irc_channel_options_with_server_default(owner, server, chan1); + THEN("we get the server option") + CHECK(r.encodingIn == "serverEncoding"); + } + } + GIVEN("An option defined for both the server and the channel") + { + s.encodingIn = "serverEncoding"; + s.update(); + c.encodingIn = "channelEncoding"; + c.update(); + WHEN("we fetch that option") + { + auto r = Database::get_irc_channel_options_with_server_default(owner, server, chan1); + THEN("we get the channel option") + CHECK(r.encodingIn == "channelEncoding"); + } + WHEN("we fetch that option, with no channel specified") + { + auto r = Database::get_irc_channel_options_with_server_default(owner, server, ""); + THEN("we get the server option") + CHECK(r.encodingIn == "serverEncoding"); + } + } + } + + Database::close(); +#endif +} diff --git a/tests/dns.cpp b/tests/dns.cpp new file mode 100644 index 0000000..c3eda7b --- /dev/null +++ b/tests/dns.cpp @@ -0,0 +1,91 @@ +#include "catch.hpp" + +#include <network/dns_handler.hpp> +#include <network/resolver.hpp> +#include <network/poller.hpp> + +#include <utils/timed_events.hpp> + +TEST_CASE("DNS resolver") +{ + Resolver resolver; + Resolver resolver2; + Resolver resolver3; + + /** + * If we are using cares, we need to run a poller loop until each + * resolution is finished. Without cares we get the result before + * resolve() returns because it’s blocking. + */ +#ifdef CARES_FOUND + auto p = std::make_shared<Poller>(); + + const auto loop = [&p]() + { + do + { + DNSHandler::instance.watch_dns_sockets(p); + } + while (p->poll(utils::no_timeout) != -1); + }; +#else + // We don’t need to do anything if we are not using cares. + const auto loop = [](){}; +#endif + + std::string hostname; + std::string port = "6667"; + + bool success = true; + + const auto error_cb = [&success](const std::string& hostname) + { + return [&success, hostname](const char *msg) + { + INFO("Failed to resolve " << hostname << ":" << msg); + success = false; + }; + }; + const auto success_cb = [&success](const std::string& hostname) + { + return [&success, hostname](const struct addrinfo *addr) + { + INFO("Successfully resolved " << hostname << ": " << addr_to_string(addr)); + success = true; + }; + }; + + hostname = "example.com"; + resolver.resolve(hostname, port, + success_cb(hostname), error_cb(hostname)); + hostname = "poez.io"; + resolver2.resolve(hostname, port, + success_cb(hostname), error_cb(hostname)); + hostname = "louiz.org"; + resolver3.resolve(hostname, port, + success_cb(hostname), error_cb(hostname)); + loop(); + CHECK(success); + + hostname = "this.should.fail.because.it.is..misformatted"; + resolver.resolve(hostname, port, + success_cb(hostname), error_cb(hostname)); + loop(); + CHECK(!success); + + hostname = "this.should.fail.because.it.is.does.not.exist.invalid"; + resolver.resolve(hostname, port, + success_cb(hostname), error_cb(hostname)); + loop(); + CHECK(!success); + + hostname = "localhost"; + resolver.resolve(hostname, port, + success_cb(hostname), error_cb(hostname)); + loop(); + CHECK(success); + +#ifdef CARES_FOUND + DNSHandler::instance.destroy(); +#endif +} diff --git a/tests/encoding.cpp b/tests/encoding.cpp new file mode 100644 index 0000000..389cf23 --- /dev/null +++ b/tests/encoding.cpp @@ -0,0 +1,56 @@ +#include "catch.hpp" + +#include <utils/encoding.hpp> + + +TEST_CASE("UTF-8 validation") +{ + const char* valid = "C̡͔͕̩͙̽ͫ̈́ͥ̿̆ͧ̚r̸̩̘͍̻͖̆͆͛͊̉̕͡o͇͈̳̤̱̊̈͢q̻͍̦̮͕ͥͬͬ̽ͭ͌̾ͅǔ͉͕͇͚̙͉̭͉̇̽ȇ͈̮̼͍͔ͣ͊͞͝ͅ ͫ̾ͪ̓ͥ̆̋̔҉̢̦̠͈͔̖̲̯̦ụ̶̯͐̃̋ͮ͆͝n̬̱̭͇̻̱̰̖̤̏͛̏̿̑͟ë́͐҉̸̥̪͕̹̻̙͉̰ ̹̼̱̦̥ͩ͑̈́͑͝ͅt͍̥͈̹̝ͣ̃̔̈̔ͧ̕͝ḙ̸̖̟̙͙ͪ͢ų̯̞̼̲͓̻̞͛̃̀́b̮̰̗̩̰̊̆͗̾̎̆ͯ͌͝.̗̙͎̦ͫ̈́ͥ͌̈̓ͬ"; + CHECK(utils::is_valid_utf8(valid)); + CHECK_FALSE(utils::is_valid_utf8("\xF0\x0F")); + CHECK_FALSE(utils::is_valid_utf8("\xFE\xFE\xFF\xFF")); + + std::string in = "Biboumi ╯°□°)╯︵ ┻━┻"; + INFO(in); + CHECK(utils::is_valid_utf8(in.data())); +} + +TEST_CASE("UTF-8 conversion") +{ + std::string in = "Biboumi ╯°□°)╯︵ ┻━┻"; + REQUIRE(utils::is_valid_utf8(in.data())); + + SECTION("Converting UTF-8 to UTF-8 should return the same string") + { + std::string res = utils::convert_to_utf8(in, "UTF-8"); + CHECK(utils::is_valid_utf8(res.c_str()) == true); + CHECK(res == in); + } + + SECTION("Testing latin-1 conversion") + { + std::string original_utf8("couc¥ou"); + std::string original_latin1("couc\xa5ou"); + + SECTION("Convert proper latin-1 to UTF-8") + { + std::string from_latin1 = utils::convert_to_utf8(original_latin1.c_str(), "ISO-8859-1"); + CHECK(from_latin1 == original_utf8); + } + SECTION("Check the behaviour when the decoding fails (here because we provide a wrong charset)") + { + std::string from_ascii = utils::convert_to_utf8(original_latin1, "US-ASCII"); + CHECK(from_ascii == "couc�ou"); + } + } +} + +TEST_CASE("Remove invalid XML chars") +{ + std::string without_ctrl_char("𤭢€¢$"); + std::string in = "Biboumi ╯°□°)╯︵ ┻━┻"; + INFO(in); + CHECK(utils::remove_invalid_xml_chars(without_ctrl_char) == without_ctrl_char); + CHECK(utils::remove_invalid_xml_chars(in) == in); + CHECK(utils::remove_invalid_xml_chars("\acouco\u0008u\uFFFEt\uFFFFe\r\n♥") == "coucoute\r\n♥"); +} diff --git a/tests/end_to_end/__main__.py b/tests/end_to_end/__main__.py new file mode 100644 index 0000000..4348197 --- /dev/null +++ b/tests/end_to_end/__main__.py @@ -0,0 +1,1027 @@ +#!/usr/bin/env python3 + +import collections +import slixmpp +import asyncio +import logging +import signal +import atexit +import lxml.etree +import sys +import io +import os +from functools import partial +from slixmpp.xmlstream.matcher.base import MatcherBase + + +class MatchAll(MatcherBase): + """match everything""" + + def match(self, xml): + return True + + +class StanzaError(Exception): + """ + Raised when a step fails. + """ + pass + + +class SkipStepError(Exception): + """ + Raised by a step when it needs to be skiped, by running + the next available step immediately. + """ + pass + + +class XMPPComponent(slixmpp.BaseXMPP): + """ + XMPPComponent sending a “scenario” of stanzas, checking that the responses + match the expected results. + """ + + def __init__(self, scenario, biboumi): + super().__init__(jid="biboumi.localhost", default_ns="jabber:component:accept") + self.is_component = True + self.stream_header = '<stream:stream %s %s from="%s" id="%s">' % ( + 'xmlns="jabber:component:accept"', + 'xmlns:stream="%s"' % self.stream_ns, + self.boundjid, self.get_id()) + self.stream_footer = "</stream:stream>" + + self.register_handler(slixmpp.Callback('Match All', + MatchAll(None), + self.handle_incoming_stanza)) + + self.add_event_handler("session_end", self.on_end_session) + + asyncio.async(self.accept_routine()) + + self.scenario = scenario + self.biboumi = biboumi + # A callable, taking a stanza as argument and raising a StanzaError + # exception if the test should fail. + self.stanza_checker = None + self.failed = False + self.accepting_server = None + + self.saved_values = {} + + def error(self, message): + print("[31;1mFailure[0m: %s" % (message,)) + self.scenario.steps = [] + self.failed = True + + def on_end_session(self, event): + self.loop.stop() + + def handle_incoming_stanza(self, stanza): + if self.stanza_checker: + try: + self.stanza_checker(stanza) + except StanzaError as e: + self.error(e) + except SkipStepError: + # Run the next step and then re-handle this same stanza + self.run_scenario() + return self.handle_incoming_stanza(stanza) + self.stanza_checker = None + self.run_scenario() + + def run_scenario(self): + if scenario.steps: + step = scenario.steps.pop(0) + step(self, self.biboumi) + else: + self.biboumi.stop() + + @asyncio.coroutine + def accept_routine(self): + self.accepting_server = yield from self.loop.create_server(lambda: self, + "127.0.0.1", "8811", reuse_address=True) + + def check_stanza_against_all_expected_xpaths(self): + pass + + +def match(stanza, xpath): + tree = lxml.etree.parse(io.StringIO(str(stanza))) + matched = tree.xpath(xpath, namespaces={'re': 'http://exslt.org/regular-expressions', + 'muc_user': 'http://jabber.org/protocol/muc#user', + 'disco_items': 'http://jabber.org/protocol/disco#items', + 'commands': 'http://jabber.org/protocol/commands', + 'dataform': 'jabber:x:data', + 'version': 'jabber:iq:version'}) + return matched + + +def check_xpath(xpaths, xmpp, after, stanza): + for i, xpath in enumerate(xpaths): + matched = match(stanza, xpath) + if not matched: + raise StanzaError("Received stanza “%s” did not match expected xpath “%s”" % (stanza, xpath)) + if after: + if isinstance(after, collections.Iterable): + for af in after: + af(stanza, xmpp) + else: + after(stanza, xmpp) + + +def check_xpath_optional(xpaths, xmpp, after, stanza): + try: + check_xpath(xpaths, xmpp, after, stanza) + except StanzaError: + raise SkipStepError() + + +class Scenario: + """Defines a list of actions that are executed in sequence, until one of + them throws an exception, or until the end. An action can be something + like “send a stanza”, “receive the next stanza and check that it matches + the given XPath”, “send a signal”, “wait for the end of the process”, + etc + """ + + def __init__(self, name, steps, conf="basic"): + """ + Steps is a list of 2-tuple: + [(action, answer), (action, answer)] + """ + self.name = name + self.steps = [] + self.conf = conf + for elem in steps: + if isinstance(elem, collections.Iterable): + for step in elem: + self.steps.append(step) + else: + self.steps.append(elem) + + +class ProcessRunner: + def __init__(self): + self.process = None + self.signal_sent = False + self.create = None + + @asyncio.coroutine + def start(self): + self.process = yield from self.create + + @asyncio.coroutine + def wait(self): + code = yield from self.process.wait() + return code + + def stop(self): + if not self.signal_sent: + self.signal_sent = True + if self.process: + self.process.send_signal(signal.SIGINT) + + def __del__(self): + self.stop() + + +class BiboumiRunner(ProcessRunner): + def __init__(self, name, with_valgrind): + super().__init__() + self.name = name + self.fd = open("biboumi_%s_output.txt" % (name,), "w") + if with_valgrind: + self.create = asyncio.create_subprocess_exec("valgrind", "--suppressions=" + (os.environ.get("E2E_BIBOUMI_SUPP_DIR") or "") + "biboumi.supp", "--leak-check=full", "--show-leak-kinds=all", + "--errors-for-leak-kinds=all", "--error-exitcode=16", + "./biboumi", "test.conf", stdin=None, stdout=self.fd, + stderr=self.fd, loop=None, limit=None) + else: + self.create = asyncio.create_subprocess_exec("./biboumi", "test.conf", stdin=None, stdout=self.fd, + stderr=self.fd, loop=None, limit=None) + + +class IrcServerRunner(ProcessRunner): + def __init__(self): + super().__init__() + self.create = asyncio.create_subprocess_exec("charybdis", "-foreground", "-configfile", os.getcwd() + "/../tests/end_to_end/ircd.conf", + stderr=asyncio.subprocess.PIPE) + + +def send_stanza(stanza, xmpp, biboumi): + replacements = common_replacements + replacements.update(xmpp.saved_values) + xmpp.send_raw(stanza.format_map(replacements)) + asyncio.get_event_loop().call_soon(xmpp.run_scenario) + + +def expect_stanza(xpaths, xmpp, biboumi, optional=False, after=None): + check_func = check_xpath if not optional else check_xpath_optional + if isinstance(xpaths, str): + xmpp.stanza_checker = partial(check_func, [xpaths.format_map(common_replacements)], xmpp, after) + elif isinstance(xpaths, tuple): + xmpp.stanza_checker = partial(check_func, [xpath.format_map(common_replacements) for xpath in xpaths], xmpp, after) + else: + print("Warning, from argument type passed to expect_stanza: %s" % (type(xpaths))) + + +def log_message(message, xmpp, biboumi): + print("[33;1m%s[0m" % (message,)) + asyncio.get_event_loop().call_soon(xmpp.run_scenario) + + +class BiboumiTest: + """ + Spawns a biboumi process and a fake XMPP Component that will run a + Scenario. It redirects the outputs of the subprocess into separated + files, and detects any failure in the running of the scenario. + """ + + def __init__(self, scenario, expected_code=0): + self.scenario = scenario + self.expected_code = expected_code + + def run(self, with_valgrind=True): + print("Running scenario: [33;1m%s[0m%s" % (self.scenario.name, " (with valgrind)" if with_valgrind else '')) + # Redirect the slixmpp logging into a specific file + output_filename = "slixmpp_%s_output.txt" % (self.scenario.name,) + with open(output_filename, "w"): + pass + logging.basicConfig(level=logging.DEBUG, + format='%(levelname)-8s %(message)s', + filename=output_filename) + + with open("test.conf", "w") as fd: + fd.write(confs[scenario.conf]) + + # Start the XMPP component and biboumi + biboumi = BiboumiRunner(scenario.name, with_valgrind) + xmpp = XMPPComponent(self.scenario, biboumi) + asyncio.get_event_loop().run_until_complete(biboumi.start()) + + asyncio.get_event_loop().call_soon(xmpp.run_scenario) + + xmpp.process() + code = asyncio.get_event_loop().run_until_complete(biboumi.wait()) + xmpp.biboumi = None + scenario.steps.clear() + failed = False + if not xmpp.failed: + if code != self.expected_code: + xmpp.error("Wrong return code from biboumi's process: %d" % (code,)) + failed = True + else: + print("[32;1mSuccess![0m") + else: + failed = True + + xmpp.saved_values.clear() + + if xmpp.server: + xmpp.accepting_server.close() + + return not failed + + +confs = { +'basic': +"""hostname=biboumi.localhost +password=coucou +db_name=e2e_test.sqlite +port=8811 +admin=admin@example.com""", + +'fixed_server': +"""hostname=biboumi.localhost +password=coucou +db_name=e2e_test.sqlite +port=8811 +fixed_irc_server=irc.localhost +admin=admin@example.com +"""} + +common_replacements = { + 'irc_server_one': 'irc.localhost@biboumi.localhost', + 'irc_host_one': 'irc.localhost', + 'biboumi_host': 'biboumi.localhost', + 'resource_one': 'resource1', + 'resource_two': 'resource2', + 'nick_one': 'Nick', + 'jid_one': 'first@example.com', + 'jid_two': 'second@example.com', + 'jid_admin': 'admin@example.com', + 'nick_two': 'Bobby', + 'lower_nick_one': 'nick', + 'lower_nick_two': 'bobby', +} + + +def handshake_sequence(): + return (partial(expect_stanza, "//handshake"), + partial(send_stanza, "<handshake xmlns='jabber:component:accept'/>")) + + +def connection_begin_sequence(irc_host, jid): + jid = jid.format_map(common_replacements) + xpath = "/message[@to='" + jid + "'][@from='irc.localhost@biboumi.localhost']/body[text()='%s']" + xpath_re = "/message[@to='" + jid + "'][@from='irc.localhost@biboumi.localhost']/body[re:test(text(), '%s')]" + return ( + partial(expect_stanza, + xpath % ('Connecting to %s:6697 (encrypted)' % irc_host)), + partial(expect_stanza, + xpath % 'Connection failed: Connection refused'), + partial(expect_stanza, + xpath % ('Connecting to %s:6670 (encrypted)' % irc_host)), + partial(expect_stanza, + xpath % 'Connection failed: Connection refused'), + partial(expect_stanza, + xpath % ('Connecting to %s:6667 (not encrypted)' % irc_host)), + partial(expect_stanza, + xpath % 'Connected to IRC server.'), + # These two messages can be receive in any order + partial(expect_stanza, + xpath_re % (r'^%s: \*\*\* (Checking Ident|Looking up your hostname...)$' % irc_host)), + partial(expect_stanza, + xpath_re % (r'^%s: \*\*\* (Checking Ident|Looking up your hostname...)$' % irc_host)), + # These three messages can be received in any order + partial(expect_stanza, + xpath_re % (r'^%s: (\*\*\* Found your hostname: .*|ACK multi-prefix|\*\*\* No Ident response)$' % irc_host)), + partial(expect_stanza, + xpath_re % (r'^%s: (\*\*\* Found your hostname: .*|ACK multi-prefix|\*\*\* No Ident response)$' % irc_host)), + partial(expect_stanza, + xpath_re % (r'^%s: (\*\*\* Found your hostname: .*|ACK multi-prefix|\*\*\* No Ident response)$' % irc_host)), + ) + + +def connection_end_sequence(irc_host, jid): + jid = jid.format_map(common_replacements) + xpath = "/message[@to='" + jid + "'][@from='irc.localhost@biboumi.localhost']/body[text()='%s']" + xpath_re = "/message[@to='" + jid + "'][@from='irc.localhost@biboumi.localhost']/body[re:test(text(), '%s')]" + return ( + partial(expect_stanza, + xpath_re % (r'^%s: Your host is .*$' % irc_host)), + partial(expect_stanza, + xpath_re % (r'^%s: This server was created .*$' % irc_host)), + partial(expect_stanza, + xpath_re % (r'^%s: There are \d+ users and \d+ invisible on \d+ servers$' % irc_host)), + partial(expect_stanza, + xpath_re % (r'^%s: \d+ unknown connection\(s\)$' % irc_host), optional=True), + partial(expect_stanza, + xpath_re % (r'^%s: \d+ channels formed$' % irc_host), optional=True), + partial(expect_stanza, + xpath_re % (r'^%s: I have \d+ clients and \d+ servers$' % irc_host)), + partial(expect_stanza, + xpath_re % (r'^%s: \d+ \d+ Current local users \d+, max \d+$' % irc_host)), + partial(expect_stanza, + xpath_re % (r'^%s: \d+ \d+ Current global users \d+, max \d+$' % irc_host)), + partial(expect_stanza, + xpath_re % (r'^%s: Highest connection count: \d+ \(\d+ clients\) \(\d+ connections received\)$' % irc_host)), + partial(expect_stanza, + xpath % "- This is charybdis MOTD you might replace it, but if not your friends will\n- laugh at you.\n"), + partial(expect_stanza, + xpath_re % r'^User mode for \w+ is \[\+i\]$'), + ) + + +def connection_sequence(irc_host, jid): + return connection_begin_sequence(irc_host, jid) + connection_end_sequence(irc_host, jid) + + +def extract_attribute(xpath, name, stanza): + matched = match(stanza, xpath) + return matched[0].get(name) + + +def save_value(name, func, stanza, xmpp): + xmpp.saved_values[name] = func(stanza) + + +if __name__ == '__main__': + + atexit.register(asyncio.get_event_loop().close) + + # Start the test component, accepting connections on the configured + # port. + scenarios = ( + Scenario("basic_handshake_success", + [ + handshake_sequence() + ]), + Scenario("irc_server_connection", + [ + handshake_sequence(), + partial(send_stanza, + "<presence from='{jid_one}/{resource_one}' to='#foo%{irc_server_one}/{nick_one}' />"), + connection_sequence("irc.localhost", '{jid_one}/{resource_one}'), + ]), + Scenario("simple_channel_join", + [ + handshake_sequence(), + partial(send_stanza, + "<presence from='{jid_one}/{resource_one}' to='#foo%{irc_server_one}/{nick_one}' />"), + connection_sequence("irc.localhost", '{jid_one}/{resource_one}'), + partial(expect_stanza, + "/message/body[text()='Mode #foo [+nt] by {irc_host_one}']"), + partial(expect_stanza, + ("/presence[@to='{jid_one}/{resource_one}'][@from='#foo%{irc_server_one}/{nick_one}']/muc_user:x/muc_user:item[@affiliation='admin'][@role='moderator']", + "/presence/muc_user:x/muc_user:status[@code='110']") + ), + partial(expect_stanza, "/message[@from='#foo%{irc_server_one}'][@type='groupchat']/subject[not(text())]"), + ]), + Scenario("virtual_channel_join", + [ + handshake_sequence(), + partial(send_stanza, + "<presence from='{jid_one}/{resource_one}' to='%{irc_server_one}/{nick_one}' />"), + connection_begin_sequence("irc.localhost", '{jid_one}/{resource_one}'), + partial(expect_stanza, + ("/presence[@to='{jid_one}/{resource_one}'][@from='%{irc_server_one}/{nick_one}']/muc_user:x/muc_user:item[@affiliation='none'][@role='participant']", + "/presence/muc_user:x/muc_user:status[@code='110']") + ), + partial(expect_stanza, "/message[@from='%{irc_server_one}'][@type='groupchat']/subject[re:test(text(), '^This is a virtual channel.*$')]"), + connection_end_sequence("irc.localhost", '{jid_one}/{resource_one}'), + ]), + Scenario("channel_join_with_two_users", + [ + handshake_sequence(), + # First user joins + partial(log_message, + "First user joins"), + partial(send_stanza, + "<presence from='{jid_one}/{resource_one}' to='#foo%{irc_server_one}/{nick_one}' />"), + connection_sequence("irc.localhost", '{jid_one}/{resource_one}'), + partial(expect_stanza, + "/message/body[text()='Mode #foo [+nt] by {irc_host_one}']"), + partial(expect_stanza, + ("/presence[@to='{jid_one}/{resource_one}'][@from='#foo%{irc_server_one}/{nick_one}']/muc_user:x/muc_user:item[@affiliation='admin'][@jid='~nick@localhost'][@role='moderator']", + "/presence/muc_user:x/muc_user:status[@code='110']") + ), + partial(expect_stanza, "/message[@from='#foo%{irc_server_one}'][@type='groupchat']/subject[not(text())]"), + + # Second user joins + partial(log_message, + "Second user joins"), + partial(send_stanza, + "<presence from='{jid_two}/{resource_one}' to='#foo%{irc_server_one}/{nick_two}' />"), + connection_sequence("irc.localhost", '{jid_two}/{resource_one}'), + # Our presence, sent to the other user + partial(log_message, + "Our presence sent to the other user"), + partial(expect_stanza, + ("/presence[@to='{jid_one}/{resource_one}'][@from='#foo%{irc_server_one}/{nick_two}']/muc_user:x/muc_user:item[@affiliation='none'][@jid='~bobby@localhost'][@role='participant']",)), + # The other user presence + partial(log_message, + "The other user presence"), + partial(expect_stanza, + "/presence[@to='{jid_second}/{resource_one}'][@from='#foo%{irc_server_one}/{nick_one}']/muc_user:x/muc_user:item[@affiliation='none'][@jid='~nick@localhost'][@role='participant']"), + # Our own presence + partial(log_message, + "Our own presence"), + partial(expect_stanza, + ("/presence[@to='{jid_two}/{resource_one}'][@from='#foo%{irc_server_one}/{nick_two}']/muc_user:x/muc_user:item[@affiliation='none'][@jid='~bobby@localhost'][@role='participant']", + "/presence/muc_user:x/muc_user:status[@code='110']") + ), + partial(expect_stanza, "/message[@from='#foo%{irc_server_one}'][@type='groupchat']/subject[not(text())]"), + ]), + Scenario("channel_custom_topic", + [ + handshake_sequence(), + # First user joins + partial(send_stanza, + "<presence from='{jid_one}/{resource_one}' to='#foo%{irc_server_one}/{nick_one}' />"), + connection_sequence("irc.localhost", '{jid_one}/{resource_one}'), + partial(expect_stanza, + "/message/body[text()='Mode #foo [+nt] by {irc_host_one}']"), + partial(expect_stanza, + ("/presence[@to='{jid_one}/{resource_one}'][@from='#foo%{irc_server_one}/{nick_one}']/muc_user:x/muc_user:item[@affiliation='admin'][@jid='~nick@localhost'][@role='moderator']", + "/presence/muc_user:x/muc_user:status[@code='110']") + ), + partial(expect_stanza, "/message[@from='#foo%{irc_server_one}'][@type='groupchat']/subject[not(text())]"), + + # First user sets the topic + partial(send_stanza, + "<message from='{jid_one}/{resource_one}' to='#foo%{irc_server_one}' type='groupchat'><subject>TOPIC TEST</subject></message>"), + partial(expect_stanza, "/message[@from='#foo%{irc_server_one}/{nick_one}'][@type='groupchat'][@to='{jid_one}/{resource_one}']/subject[text()='TOPIC TEST']"), + + # Second user joins + partial(send_stanza, + "<presence from='{jid_two}/{resource_one}' to='#foo%{irc_server_one}/{nick_two}' />"), + connection_sequence("irc.localhost", '{jid_two}/{resource_one}'), + # Our presence, sent to the other user + partial(expect_stanza, + ("/presence[@to='{jid_one}/{resource_one}'][@from='#foo%{irc_server_one}/{nick_two}']/muc_user:x/muc_user:item[@affiliation='none'][@jid='~bobby@localhost'][@role='participant']",)), + # The other user presence + partial(expect_stanza, + "/presence[@to='{jid_second}/{resource_one}'][@from='#foo%{irc_server_one}/{nick_one}']/muc_user:x/muc_user:item[@affiliation='admin'][@jid='~nick@localhost'][@role='moderator']"), + # Our own presence + partial(expect_stanza, + ("/presence[@to='{jid_two}/{resource_one}'][@from='#foo%{irc_server_one}/{nick_two}']/muc_user:x/muc_user:item[@affiliation='none'][@jid='~bobby@localhost'][@role='participant']", + "/presence/muc_user:x/muc_user:status[@code='110']") + ), + partial(expect_stanza, "/message[@from='#foo%{irc_server_one}/{nick_one}'][@type='groupchat']/subject[text()='TOPIC TEST']"), + ]), + Scenario("channel_basic_join_on_fixed_irc_server", + [ + handshake_sequence(), + partial(send_stanza, + "<presence from='{jid_one}/{resource_one}' to='#zgeg@{biboumi_host}/{nick_one}' />"), + connection_sequence("irc.localhost", '{jid_one}/{resource_one}'), + partial(expect_stanza, + "/message/body[text()='Mode #zgeg [+nt] by {irc_host_one}']"), + partial(expect_stanza, + ("/presence[@to='{jid_one}/{resource_one}'][@from='#zgeg@{biboumi_host}/{nick_one}']/muc_user:x/muc_user:item[@affiliation='admin'][@role='moderator']", + "/presence/muc_user:x/muc_user:status[@code='110']") + ), + partial(expect_stanza, "/message[@from='#zgeg@{biboumi_host}'][@type='groupchat']/subject[not(text())]"), + ], conf='fixed_server' + ), + Scenario("list_adhoc", + [ + handshake_sequence(), + partial(send_stanza, "<iq type='get' id='idwhatever' from='{jid_one}/{resource_one}' to='{biboumi_host}'><query xmlns='http://jabber.org/protocol/disco#items' node='http://jabber.org/protocol/commands' /></iq>"), + partial(expect_stanza, ("/iq[@type='result']/disco_items:query[@node='http://jabber.org/protocol/commands']", + "/iq/disco_items:query/disco_items:item[3]")), + ]), + Scenario("list_admin_adhoc", + [ + handshake_sequence(), + partial(send_stanza, "<iq type='get' id='idwhatever' from='{jid_admin}/{resource_one}' to='{biboumi_host}'><query xmlns='http://jabber.org/protocol/disco#items' node='http://jabber.org/protocol/commands' /></iq>"), + partial(expect_stanza, ("/iq[@type='result']/disco_items:query[@node='http://jabber.org/protocol/commands']", + "/iq/disco_items:query/disco_items:item[5]")), + ]), + Scenario("list_adhoc_fixed_server", + [ + handshake_sequence(), + partial(send_stanza, "<iq type='get' id='idwhatever' from='{jid_one}/{resource_one}' to='{biboumi_host}'><query xmlns='http://jabber.org/protocol/disco#items' node='http://jabber.org/protocol/commands' /></iq>"), + partial(expect_stanza, ("/iq[@type='result']/disco_items:query[@node='http://jabber.org/protocol/commands']", + "/iq/disco_items:query/disco_items:item[3]")), + ], conf='fixed_server'), + Scenario("list_admin_adhoc_fixed_server", + [ + handshake_sequence(), + partial(send_stanza, "<iq type='get' id='idwhatever' from='{jid_admin}/{resource_one}' to='{biboumi_host}'><query xmlns='http://jabber.org/protocol/disco#items' node='http://jabber.org/protocol/commands' /></iq>"), + partial(expect_stanza, ("/iq[@type='result']/disco_items:query[@node='http://jabber.org/protocol/commands']", + "/iq/disco_items:query/disco_items:item[5]")), + ], conf='fixed_server'), + + + Scenario("list_adhoc_irc", + [ + handshake_sequence(), + partial(send_stanza, "<iq type='get' id='idwhatever' from='{jid_one}/{resource_one}' to='{irc_host_one}@{biboumi_host}'><query xmlns='http://jabber.org/protocol/disco#items' node='http://jabber.org/protocol/commands' /></iq>"), + partial(expect_stanza, ("/iq[@type='result']/disco_items:query[@node='http://jabber.org/protocol/commands']", + "/iq/disco_items:query/disco_items:item[1]")), + ]), + Scenario("list_adhoc_irc_fixed_server", + [ + handshake_sequence(), + partial(send_stanza, "<iq type='get' id='idwhatever' from='{jid_one}/{resource_one}' to='{biboumi_host}'><query xmlns='http://jabber.org/protocol/disco#items' node='http://jabber.org/protocol/commands' /></iq>"), + partial(expect_stanza, ("/iq[@type='result']/disco_items:query[@node='http://jabber.org/protocol/commands']", + "/iq/disco_items:query/disco_items:item[4]")), + ], conf='fixed_server'), + Scenario("list_admin_adhoc_irc_fixed_server", + [ + handshake_sequence(), + partial(send_stanza, "<iq type='get' id='idwhatever' from='{jid_admin}/{resource_one}' to='{biboumi_host}'><query xmlns='http://jabber.org/protocol/disco#items' node='http://jabber.org/protocol/commands' /></iq>"), + partial(expect_stanza, ("/iq[@type='result']/disco_items:query[@node='http://jabber.org/protocol/commands']", + "/iq/disco_items:query/disco_items:item[6]")), + ], conf='fixed_server'), + + Scenario("execute_hello_adhoc_command", + [ + handshake_sequence(), + partial(send_stanza, "<iq type='set' id='hello-command1' from='{jid_one}/{resource_one}' to='{biboumi_host}'><command xmlns='http://jabber.org/protocol/commands' node='hello' action='execute' /></iq>"), + partial(expect_stanza, ("/iq[@type='result']/commands:command[@node='hello'][@sessionid][@status='executing']", + "/iq/commands:command/dataform:x[@type='form']/dataform:title[text()='Configure your name.']", + "/iq/commands:command/dataform:x[@type='form']/dataform:instructions[text()='Please provide your name.']", + "/iq/commands:command/dataform:x[@type='form']/dataform:field[@type='text-single']/dataform:required", + "/iq/commands:command/commands:actions/commands:next", + ), + after = partial(save_value, "sessionid", partial(extract_attribute, "/iq[@type='result']/commands:command[@node='hello']", "sessionid")) + + ), + partial(send_stanza, "<iq type='set' id='hello-command2' from='{jid_one}/{resource_one}' to='{biboumi_host}'><command xmlns='http://jabber.org/protocol/commands' node='hello' sessionid='{sessionid}' action='next'><x xmlns='jabber:x:data' type='submit'><field var='name'><value>COUCOU</value></field></x></command></iq>"), + partial(expect_stanza, "/iq[@type='result']/commands:command[@node='hello'][@status='completed']/commands:note[@type='info'][text()='Hello COUCOU!']") + ]), + Scenario("multisessionnick", + [ + handshake_sequence(), + partial(send_stanza, + "<presence from='{jid_one}/{resource_one}' to='#foo%{irc_server_one}/{nick_one}' />"), + connection_sequence("irc.localhost", '{jid_one}/{resource_one}'), + partial(expect_stanza, + "/message/body[text()='Mode #foo [+nt] by {irc_host_one}']"), + partial(expect_stanza, + ("/presence[@to='{jid_one}/{resource_one}'][@from='#foo%{irc_server_one}/{nick_one}']/muc_user:x/muc_user:item[@affiliation='admin'][@role='moderator']", + "/presence/muc_user:x/muc_user:status[@code='110']") + ), + partial(expect_stanza, "/message[@from='#foo%{irc_server_one}'][@type='groupchat'][@to='{jid_one}/{resource_one}']/subject[not(text())]"), + + # The other resources joins the same room, with the same nick + partial(send_stanza, + "<presence from='{jid_one}/{resource_two}' to='#foo%{irc_server_one}/{nick_one}' />"), + # We receive our own join + partial(expect_stanza, + ("/presence[@to='{jid_one}/{resource_two}'][@from='#foo%{irc_server_one}/{nick_one}']/muc_user:x/muc_user:item[@affiliation='admin'][@role='moderator']", + "/presence/muc_user:x/muc_user:status[@code='110']") + ), + partial(expect_stanza, "/message[@from='#foo%{irc_server_one}'][@type='groupchat'][@to='{jid_one}/{resource_two}']/subject[not(text())]"), + + # A different user joins the same room + partial(send_stanza, + "<presence from='{jid_two}/{resource_one}' to='#foo%{irc_server_one}/{nick_two}' />"), + connection_sequence("irc.localhost", '{jid_two}/{resource_one}'), + + partial(expect_stanza, + ("/presence[@to='{jid_one}/{resource_one}'][@from='#foo%{irc_server_one}/{nick_two}']",)), + partial(expect_stanza, + ("/presence[@to='{jid_one}/{resource_two}'][@from='#foo%{irc_server_one}/{nick_two}']",)), + + partial(expect_stanza, + "/presence[@to='{jid_second}/{resource_one}'][@from='#foo%{irc_server_one}/{nick_one}']"), + partial(expect_stanza, + ("/presence[@to='{jid_two}/{resource_one}'][@from='#foo%{irc_server_one}/{nick_two}']", + "/presence/muc_user:x/muc_user:status[@code='110']") + ), + partial(expect_stanza, "/message[@from='#foo%{irc_server_one}'][@type='groupchat']/subject[not(text())]"), + + # That second user sends a private message to the first one + partial(send_stanza, "<message from='{jid_two}/{resource_one}' to='#foo%{irc_server_one}/{nick_one}' type='chat'><body>RELLO</body></message>"), + # Message is received with a server-wide JID, by the two resources behind nick_one + partial(expect_stanza, "/message[@from='{lower_nick_two}!{irc_server_one}'][@to='{jid_one}/{resource_one}'][@type='chat']/body[text()='RELLO']"), + partial(expect_stanza, "/message[@from='{lower_nick_two}!{irc_server_one}'][@to='{jid_one}/{resource_two}'][@type='chat']/body[text()='RELLO']"), + + # One resource leaves the server entirely. + partial(send_stanza, "<presence type='unavailable' from='{jid_one}/{resource_two}' to='#foo%{irc_server_one}/{nick_one}' />"), + # The leave is forwarded only to us + partial(expect_stanza, + ("/presence[@type='unavailable']/muc_user:x/muc_user:status[@code='110']", + "/presence/status[text()='Biboumi note: 1 resources are still in this channel.']") + ), + # The second user sends two new private messages to the first user + partial(send_stanza, "<message from='{jid_two}/{resource_one}' to='#foo%{irc_server_one}/{nick_one}' type='chat'><body>first</body></message>"), + partial(send_stanza, "<message from='{jid_two}/{resource_one}' to='#foo%{irc_server_one}/{nick_one}' type='chat'><body>second</body></message>"), + # The first user receives the two messages, on the connected resource, once each + partial(expect_stanza, "/message[@from='{lower_nick_two}!{irc_server_one}'][@to='{jid_one}/{resource_one}'][@type='chat']/body[text()='first']"), + partial(expect_stanza, "/message[@from='{lower_nick_two}!{irc_server_one}'][@to='{jid_one}/{resource_one}'][@type='chat']/body[text()='second']"), + + + ]), + Scenario("channel_messages", + [ + handshake_sequence(), + # First user joins + partial(send_stanza, + "<presence from='{jid_one}/{resource_one}' to='#foo%{irc_server_one}/{nick_one}' />"), + connection_sequence("irc.localhost", '{jid_one}/{resource_one}'), + partial(expect_stanza, + "/message/body[text()='Mode #foo [+nt] by {irc_host_one}']"), + partial(expect_stanza, + ("/presence[@to='{jid_one}/{resource_one}'][@from='#foo%{irc_server_one}/{nick_one}']/muc_user:x/muc_user:item[@affiliation='admin'][@jid='~nick@localhost'][@role='moderator']", + "/presence/muc_user:x/muc_user:status[@code='110']") + ), + partial(expect_stanza, "/message[@from='#foo%{irc_server_one}'][@type='groupchat']/subject[not(text())]"), + + # Second user joins + partial(send_stanza, + "<presence from='{jid_two}/{resource_one}' to='#foo%{irc_server_one}/{nick_two}' />"), + connection_sequence("irc.localhost", '{jid_two}/{resource_one}'), + # Our presence, sent to the other user + partial(expect_stanza, + ("/presence[@to='{jid_one}/{resource_one}'][@from='#foo%{irc_server_one}/{nick_two}']/muc_user:x/muc_user:item[@affiliation='none'][@jid='~bobby@localhost'][@role='participant']",)), + # The other user presence + partial(expect_stanza, + "/presence[@to='{jid_second}/{resource_one}'][@from='#foo%{irc_server_one}/{nick_one}']/muc_user:x/muc_user:item[@affiliation='none'][@jid='~nick@localhost'][@role='participant']"), + # Our own presence + partial(expect_stanza, + ("/presence[@to='{jid_two}/{resource_one}'][@from='#foo%{irc_server_one}/{nick_two}']/muc_user:x/muc_user:item[@affiliation='none'][@jid='~bobby@localhost'][@role='participant']", + "/presence/muc_user:x/muc_user:status[@code='110']") + ), + partial(expect_stanza, "/message[@from='#foo%{irc_server_one}'][@type='groupchat']/subject[not(text())]"), + + # Send a channel message + partial(send_stanza, "<message from='{jid_one}/{resource_one}' to='#foo%{irc_server_one}' type='groupchat'><body>coucou</body></message>"), + # Receive the message, forwarded to the two users + partial(expect_stanza, "/message[@from='#foo%{irc_server_one}/{nick_one}'][@to='{jid_one}/{resource_one}'][@type='groupchat']/body[text()='coucou']"), + partial(expect_stanza, "/message[@from='#foo%{irc_server_one}/{nick_one}'][@to='{jid_two}/{resource_one}'][@type='groupchat']/body[text()='coucou']"), + + # Send a private message, to a in-room JID + partial(send_stanza, "<message from='{jid_one}/{resource_one}' to='#foo%{irc_server_one}/{nick_two}' type='chat'><body>coucou in private</body></message>"), + # Message is received with a server-wide JID + partial(expect_stanza, "/message[@from='{lower_nick_one}!{irc_server_one}'][@to='{jid_two}/{resource_one}'][@type='chat']/body[text()='coucou in private']"), + + # Respond to the message, to the server-wide JID + partial(send_stanza, "<message from='{jid_two}/{resource_one}' to='{lower_nick_one}!{irc_server_one}' type='chat'><body>yes</body></message>"), + # The response is received from the in-room JID + partial(expect_stanza, "/message[@from='#foo%{irc_server_one}/{nick_two}'][@to='{jid_one}/{resource_one}'][@type='chat']/body[text()='yes']"), + + ## Do the exact same thing, from a different chan, + # to check if the response comes from the right JID + + # Join the virtual channel + partial(send_stanza, + "<presence from='{jid_one}/{resource_one}' to='%{irc_server_one}/{nick_one}' />"), + partial(expect_stanza, + "/presence/muc_user:x/muc_user:status[@code='110']"), + partial(expect_stanza, "/message[@from='%{irc_server_one}'][@type='groupchat']/subject"), + + + # Send a private message, to a in-room JID + partial(send_stanza, "<message from='{jid_one}/{resource_one}' to='%{irc_server_one}/{nick_two}' type='chat'><body>re in private</body></message>"), + # Message is received with a server-wide JID + partial(expect_stanza, "/message[@from='{lower_nick_one}!{irc_server_one}'][@to='{jid_two}/{resource_one}'][@type='chat']/body[text()='re in private']"), + + # Respond to the message, to the server-wide JID + partial(send_stanza, "<message from='{jid_two}/{resource_one}' to='{lower_nick_one}!{irc_server_one}' type='chat'><body>re</body></message>"), + # The response is received from the in-room JID + partial(expect_stanza, "/message[@from='%{irc_server_one}/{nick_two}'][@to='{jid_one}/{resource_one}'][@type='chat']/body[text()='re']"), + + # Now we leave the room, to check if the subsequent private messages are still received properly + partial(send_stanza, + "<presence from='{jid_one}/{resource_one}' to='%{irc_server_one}/{nick_one}' type='unavailable' />"), + partial(expect_stanza, + "/presence[@type='unavailable']/muc_user:x/muc_user:status[@code='110']"), + + # The private messages from this nick should now come (again) from the server-wide JID + partial(send_stanza, "<message from='{jid_two}/{resource_one}' to='{lower_nick_one}!{irc_server_one}' type='chat'><body>hihihoho</body></message>"), + partial(expect_stanza, + "/message[@from='{lower_nick_two}!{irc_server_one}'][@to='{jid_one}/{resource_one}']"), + ] + ), + Scenario("encoded_channel_join", + [ + handshake_sequence(), + partial(send_stanza, + "<presence from='{jid_one}/{resource_one}' to='#biboumi\\40louiz.org\\3a80%{irc_server_one}/{nick_one}' />"), + connection_sequence("irc.localhost", '{jid_one}/{resource_one}'), + partial(expect_stanza, + "/message/body[text()='Mode #biboumi@louiz.org:80 [+nt] by {irc_host_one}']"), + partial(expect_stanza, + ("/presence[@to='{jid_one}/{resource_one}'][@from='#biboumi\\40louiz.org\\3a80%{irc_server_one}/{nick_one}']/muc_user:x/muc_user:item[@affiliation='admin'][@role='moderator']", + "/presence/muc_user:x/muc_user:status[@code='110']") + ), + partial(expect_stanza, "/message[@from='#biboumi\\40louiz.org\\3a80%{irc_server_one}'][@type='groupchat']/subject[not(text())]"), + ]), + Scenario("self_ping_on_real_channel", + [ + handshake_sequence(), + partial(send_stanza, + "<presence from='{jid_one}/{resource_one}' to='#foo%{irc_server_one}/{nick_one}' />"), + connection_sequence("irc.localhost", '{jid_one}/{resource_one}'), + partial(expect_stanza, + "/message/body[text()='Mode #foo [+nt] by {irc_host_one}']"), + partial(expect_stanza, + ("/presence[@to='{jid_one}/{resource_one}'][@from='#foo%{irc_server_one}/{nick_one}']/muc_user:x/muc_user:item[@affiliation='admin'][@role='moderator']", + "/presence/muc_user:x/muc_user:status[@code='110']") + ), + partial(expect_stanza, "/message[@from='#foo%{irc_server_one}'][@type='groupchat']/subject[not(text())]"), + + # Send a ping to ourself + partial(send_stanza, + "<iq type='get' from='{jid_one}/{resource_one}' id='first_ping' to='#foo%{irc_server_one}/{nick_one}'><ping xmlns='urn:xmpp:ping' /></iq>"), + # We receive our own ping request, + partial(expect_stanza, + "/iq[@from='{lower_nick_one}!{irc_server_one}'][@type='get'][@to='{jid_one}/{resource_one}'][@id='gnip_tsrif']"), + # Respond to the request + partial(send_stanza, + "<iq type='result' to='{lower_nick_one}!{irc_server_one}' id='gnip_tsrif' from='{jid_one}/{resource_one}'/>"), + partial(expect_stanza, + "/iq[@from='#foo%{irc_server_one}/{nick_one}'][@type='result'][@to='{jid_one}/{resource_one}'][@id='first_ping']"), + + # Now join the same room, from the same bare JID, behind the same nick + partial(send_stanza, + "<presence from='{jid_one}/{resource_two}' to='#foo%{irc_server_one}/{nick_one}' />"), + partial(expect_stanza, + ("/presence[@to='{jid_one}/{resource_two}'][@from='#foo%{irc_server_one}/{nick_one}']/muc_user:x/muc_user:item[@affiliation='admin'][@role='moderator']", + "/presence/muc_user:x/muc_user:status[@code='110']") + ), + partial(expect_stanza, "/message[@from='#foo%{irc_server_one}'][@type='groupchat'][@to='{jid_one}/{resource_two}']/subject[not(text())]"), + + # And re-send a self ping + partial(send_stanza, + "<iq type='get' from='{jid_one}/{resource_one}' id='second_ping' to='#foo%{irc_server_one}/{nick_one}'><ping xmlns='urn:xmpp:ping' /></iq>"), + # We receive our own ping request. Note that we don't know the to value, it could be one of our two resources. + partial(expect_stanza, + "/iq[@from='{lower_nick_one}!{irc_server_one}'][@type='get'][@to][@id='gnip_dnoces']", + after = partial(save_value, "to", partial(extract_attribute, "/iq", "to"))), + # Respond to the request, using the extracted 'to' value as our 'from' + partial(send_stanza, + "<iq type='result' to='{lower_nick_one}!{irc_server_one}' id='gnip_dnoces' from='{to}'/>"), + partial(expect_stanza, + "/iq[@from='#foo%{irc_server_one}/{nick_one}'][@type='result'][@to='{jid_one}/{resource_one}'][@id='second_ping']"), + ## And re-do exactly the same thing, just change the resource initiating the self ping + partial(send_stanza, + "<iq type='get' from='{jid_one}/{resource_two}' id='third_ping' to='#foo%{irc_server_one}/{nick_one}'><ping xmlns='urn:xmpp:ping' /></iq>"), + partial(expect_stanza, + "/iq[@from='{lower_nick_one}!{irc_server_one}'][@type='get'][@to][@id='gnip_driht']", + after = partial(save_value, "to", partial(extract_attribute, "/iq", "to"))), + partial(send_stanza, + "<iq type='result' to='{lower_nick_one}!{irc_server_one}' id='gnip_driht' from='{to}'/>"), + partial(expect_stanza, + "/iq[@from='#foo%{irc_server_one}/{nick_one}'][@type='result'][@to='{jid_one}/{resource_two}'][@id='third_ping']"), + + ]), + Scenario("simple_kick", + [ + handshake_sequence(), + # First user joins + partial(send_stanza, + "<presence from='{jid_one}/{resource_one}' to='#foo%{irc_server_one}/{nick_one}' />"), + connection_sequence("irc.localhost", '{jid_one}/{resource_one}'), + partial(expect_stanza, "/message"), + partial(expect_stanza, "/presence/muc_user:x/muc_user:status[@code='110']"), + partial(expect_stanza, "/message[@type='groupchat']/subject"), + + # Second user joins + partial(send_stanza, + "<presence from='{jid_two}/{resource_one}' to='#foo%{irc_server_one}/{nick_two}' />"), + connection_sequence("irc.localhost", '{jid_two}/{resource_one}'), + partial(expect_stanza, + "/presence/muc_user:x/muc_user:item[@affiliation='none'][@role='participant']",), + + partial(expect_stanza, + "/presence/muc_user:x/muc_user:item[@affiliation='admin'][@role='moderator']"), + partial(expect_stanza, + "/presence/muc_user:x/muc_user:status[@code='110']"), + partial(expect_stanza, "/message/subject"), + + # Moderator kicks participant + partial(log_message, "Moderator kicks participant"), + partial(send_stanza, + "<iq id='kick1' to='#foo%{irc_server_one}' from='{jid_one}/{resource_one}' type='set'><query xmlns='http://jabber.org/protocol/muc#admin'><item nick='{nick_two}' role='none'><reason>reported</reason></item></query></iq>"), + partial(log_message, "Presence is sent to everyone"), + partial(expect_stanza, + ("/presence[@type='unavailable'][@to='{jid_second}/{resource_one}']/muc_user:x/muc_user:item[@role='none']/muc_user:actor[@nick='{nick_one}']", + "/presence/muc_user:x/muc_user:item/muc_user:reason[text()='reported']", + "/presence/muc_user:x/muc_user:status[@code='307']", + "/presence/muc_user:x/muc_user:status[@code='110']" + )), + partial(expect_stanza, + ("/presence[@type='unavailable']/muc_user:x/muc_user:item[@role='none']/muc_user:actor[@nick='{nick_one}']", + "/presence/muc_user:x/muc_user:item/muc_user:reason[text()='reported']", + "/presence/muc_user:x/muc_user:status[@code='307']", + ), + ), + partial(expect_stanza, + "/iq[@id='kick1'][@type='result']"), + ]), + Scenario("multisession_kick", + [ + handshake_sequence(), + # First user joins + partial(send_stanza, + "<presence from='{jid_one}/{resource_one}' to='#foo%{irc_server_one}/{nick_one}' />"), + connection_sequence("irc.localhost", '{jid_one}/{resource_one}'), + partial(expect_stanza, "/message"), + partial(expect_stanza, "/presence/muc_user:x/muc_user:status[@code='110']"), + partial(expect_stanza, "/message[@type='groupchat']/subject"), + + # Second user joins, from two resources + partial(send_stanza, + "<presence from='{jid_two}/{resource_one}' to='#foo%{irc_server_one}/{nick_two}' />"), + connection_sequence("irc.localhost", '{jid_two}/{resource_one}'), + partial(expect_stanza, + "/presence/muc_user:x/muc_user:item[@affiliation='none'][@role='participant']",), + + partial(expect_stanza, + "/presence/muc_user:x/muc_user:item[@affiliation='admin'][@role='moderator']"), + partial(expect_stanza, + "/presence/muc_user:x/muc_user:status[@code='110']"), + partial(expect_stanza, "/message/subject"), + + partial(send_stanza, + "<presence from='{jid_two}/{resource_two}' to='#foo%{irc_server_one}/{nick_two}' />"), + partial(expect_stanza, + "/presence[@to='{jid_two}/{resource_two}'][@from='#foo%{irc_server_one}/{nick_one}']"), + partial(expect_stanza, + ("/presence[@to='{jid_two}/{resource_two}'][@from='#foo%{irc_server_one}/{nick_two}']", + "/presence/muc_user:x/muc_user:status[@code='110']") + ), + partial(expect_stanza, "/message[@from='#foo%{irc_server_one}'][@type='groupchat'][@to='{jid_two}/{resource_two}']/subject[not(text())]"), + + # Moderator kicks participant + partial(log_message, "Moderator kicks participant"), + partial(send_stanza, + "<iq id='kick1' to='#foo%{irc_server_one}' from='{jid_one}/{resource_one}' type='set'><query xmlns='http://jabber.org/protocol/muc#admin'><item nick='{nick_two}' role='none'><reason>reported</reason></item></query></iq>"), + partial(log_message, "Unavailable presence is sent to the two resources"), + partial(expect_stanza, + ("/presence[@type='unavailable'][@to='{jid_second}/{resource_one}']/muc_user:x/muc_user:item[@role='none']/muc_user:actor[@nick='{nick_one}']", + "/presence/muc_user:x/muc_user:item/muc_user:reason[text()='reported']", + "/presence/muc_user:x/muc_user:status[@code='307']", + "/presence/muc_user:x/muc_user:status[@code='110']" + )), + partial(expect_stanza, + ("/presence[@type='unavailable'][@to='{jid_second}/{resource_two}']/muc_user:x/muc_user:item[@role='none']/muc_user:actor[@nick='{nick_one}']", + "/presence/muc_user:x/muc_user:item/muc_user:reason[text()='reported']", + "/presence/muc_user:x/muc_user:status[@code='307']", + "/presence/muc_user:x/muc_user:status[@code='110']" + )), + partial(expect_stanza, + ("/presence[@type='unavailable']/muc_user:x/muc_user:item[@role='none']/muc_user:actor[@nick='{nick_one}']", + "/presence/muc_user:x/muc_user:item/muc_user:reason[text()='reported']", + "/presence/muc_user:x/muc_user:status[@code='307']", + ), + ), + partial(expect_stanza, + "/iq[@id='kick1'][@type='result']"), + ]), + Scenario("self_version", + [ + handshake_sequence(), + partial(send_stanza, + "<presence from='{jid_one}/{resource_one}' to='#foo%{irc_server_one}/{nick_one}' />"), + connection_sequence("irc.localhost", '{jid_one}/{resource_one}'), + partial(expect_stanza, + "/message/body[text()='Mode #foo [+nt] by {irc_host_one}']"), + partial(expect_stanza, + ("/presence[@to='{jid_one}/{resource_one}'][@from='#foo%{irc_server_one}/{nick_one}']/muc_user:x/muc_user:item[@affiliation='admin'][@role='moderator']", + "/presence/muc_user:x/muc_user:status[@code='110']") + ), + partial(expect_stanza, "/message[@from='#foo%{irc_server_one}'][@type='groupchat']/subject[not(text())]"), + + # Send a version request to ourself + partial(send_stanza, + "<iq type='get' from='{jid_one}/{resource_one}' id='first_version' to='#foo%{irc_server_one}/{nick_one}'><query xmlns='jabber:iq:version' /></iq>"), + # We receive our own request, + partial(expect_stanza, + "/iq[@from='{lower_nick_one}!{irc_server_one}'][@type='get'][@to='{jid_one}/{resource_one}']", + after = partial(save_value, "id", partial(extract_attribute, "/iq", 'id'))), + # Respond to the request + partial(send_stanza, + "<iq type='result' to='{lower_nick_one}!{irc_server_one}' id='{id}' from='{jid_one}/{resource_one}'><query xmlns='jabber:iq:version'><name>e2e test</name><version>1.0</version><os>Fedora</os></query></iq>"), + partial(expect_stanza, + "/iq[@from='#foo%{irc_server_one}/{nick_one}'][@type='result'][@to='{jid_one}/{resource_one}'][@id='first_version']/version:query/version:name[text()='e2e test (through the biboumi gateway) 1.0 Fedora']"), + + # Now join the same room, from the same bare JID, behind the same nick + partial(send_stanza, + "<presence from='{jid_one}/{resource_two}' to='#foo%{irc_server_one}/{nick_one}' />"), + partial(expect_stanza, + ("/presence[@to='{jid_one}/{resource_two}'][@from='#foo%{irc_server_one}/{nick_one}']/muc_user:x/muc_user:item[@affiliation='admin'][@role='moderator']", + "/presence/muc_user:x/muc_user:status[@code='110']") + ), + partial(expect_stanza, "/message[@from='#foo%{irc_server_one}'][@type='groupchat'][@to='{jid_one}/{resource_two}']/subject[not(text())]"), + + # And re-send a self ping + partial(send_stanza, + "<iq type='get' from='{jid_one}/{resource_two}' id='second_version' to='#foo%{irc_server_one}/{nick_one}'><query xmlns='jabber:iq:version' /></iq>"), + # We receive our own request. Note that we don't know the to value, it could be one of our two resources. + partial(expect_stanza, + "/iq[@from='{lower_nick_one}!{irc_server_one}'][@type='get'][@to]", + after = (partial(save_value, "to", partial(extract_attribute, "/iq", "to")), + partial(save_value, "id", partial(extract_attribute, "/iq", "id")))), + # Respond to the request, using the extracted 'to' value as our 'from' + partial(send_stanza, + "<iq type='result' to='{lower_nick_one}!{irc_server_one}' id='{id}' from='{to}'><query xmlns='jabber:iq:version'><name>e2e test</name><version>1.0</version><os>Fedora</os></query></iq>"), + partial(expect_stanza, + "/iq[@from='#foo%{irc_server_one}/{nick_one}'][@type='result'][@to='{jid_one}/{resource_two}'][@id='second_version']"), + + # And do exactly the same thing, but initiated by the other resource + partial(send_stanza, + "<iq type='get' from='{jid_one}/{resource_one}' id='second_version' to='#foo%{irc_server_one}/{nick_one}'><query xmlns='jabber:iq:version' /></iq>"), + # We receive our own request. Note that we don't know the to value, it could be one of our two resources. + partial(expect_stanza, + "/iq[@from='{lower_nick_one}!{irc_server_one}'][@type='get'][@to]", + after = (partial(save_value, "to", partial(extract_attribute, "/iq", "to")), + partial(save_value, "id", partial(extract_attribute, "/iq", "id")))), + # Respond to the request, using the extracted 'to' value as our 'from' + partial(send_stanza, + "<iq type='result' to='{lower_nick_one}!{irc_server_one}' id='{id}' from='{to}'><query xmlns='jabber:iq:version'><name>e2e test</name><version>1.0</version><os>Fedora</os></query></iq>"), + partial(expect_stanza, + "/iq[@from='#foo%{irc_server_one}/{nick_one}'][@type='result'][@to='{jid_one}/{resource_one}'][@id='second_version']"), + ]), + ) + + failures = 0 + + irc_output = open("irc_output.txt", "w") + irc = IrcServerRunner() + print("Starting irc server…") + asyncio.get_event_loop().run_until_complete(irc.start()) + while True: + res = asyncio.get_event_loop().run_until_complete(irc.process.stderr.readline()) + irc_output.write(res.decode()) + if not res: + print("IRC server failed to start, see irc_output.txt for more details. Exiting…") + sys.exit(1) + if b"now running in foreground mode" in res: + break + print("irc server started.") + print("Running %s checks for biboumi." % (len(scenarios))) + + for scenario in scenarios: + test = BiboumiTest(scenario) + if not test.run(os.getenv("E2E_BIBOUMI_VALGRIND") is not None): + print("You can check the files slixmpp_%s_output.txt and biboumi_%s_output.txt to help you debug." % + (scenario.name, scenario.name)) + failures += 1 + + print("Waiting for irc server to exit…") + irc.stop() + asyncio.get_event_loop().run_until_complete(irc.wait()) + + if failures: + print("%d test%s failed, please fix %s." % (failures, 's' if failures > 1 else '', + 'them' if failures > 1 else 'it')) + sys.exit(1) + else: + print("All tests passed successfully") diff --git a/tests/end_to_end/biboumi.supp b/tests/end_to_end/biboumi.supp new file mode 100644 index 0000000..d153665 --- /dev/null +++ b/tests/end_to_end/biboumi.supp @@ -0,0 +1,10 @@ +{ + stdlibc++ thingy + Memcheck:Leak + match-leak-kinds: reachable + fun:malloc + ... + fun:call_init.part.0 + fun:_dl_init + ... +} diff --git a/tests/end_to_end/ircd.conf b/tests/end_to_end/ircd.conf new file mode 100644 index 0000000..7edb3a8 --- /dev/null +++ b/tests/end_to_end/ircd.conf @@ -0,0 +1,510 @@ +/* doc/ircd.conf.example - brief example configuration file + * + * Copyright (C) 2000-2002 Hybrid Development Team + * Copyright (C) 2002-2005 ircd-ratbox development team + * Copyright (C) 2005-2006 charybdis development team + * + * See reference.conf for more information. + */ + +/* Extensions */ +#loadmodule "extensions/chm_operonly_compat"; +#loadmodule "extensions/chm_quietunreg_compat"; +#loadmodule "extensions/chm_sslonly_compat"; +#loadmodule "extensions/chm_operpeace"; +#loadmodule "extensions/createauthonly"; +#loadmodule "extensions/extb_account"; +#loadmodule "extensions/extb_canjoin"; +#loadmodule "extensions/extb_channel"; +#loadmodule "extensions/extb_combi"; +#loadmodule "extensions/extb_extgecos"; +#loadmodule "extensions/extb_hostmask"; +#loadmodule "extensions/extb_oper"; +#loadmodule "extensions/extb_realname"; +#loadmodule "extensions/extb_server"; +#loadmodule "extensions/extb_ssl"; +#loadmodule "extensions/extb_usermode"; +#loadmodule "extensions/hurt"; +#loadmodule "extensions/m_extendchans"; +#loadmodule "extensions/m_findforwards"; +#loadmodule "extensions/m_identify"; +#loadmodule "extensions/m_locops"; +#loadmodule "extensions/no_oper_invis"; +#loadmodule "extensions/sno_farconnect"; +#loadmodule "extensions/sno_globalkline"; +#loadmodule "extensions/sno_globalnickchange"; +#loadmodule "extensions/sno_globaloper"; +#loadmodule "extensions/sno_whois"; +#loadmodule "extensions/override"; +#loadmodule "extensions/no_kill_services"; + +/* + * IP cloaking extensions: use ip_cloaking_4.0 + * if you're linking 3.2 and later, otherwise use + * ip_cloaking, for compatibility with older 3.x + * releases. + */ + +#loadmodule "extensions/ip_cloaking_4.0"; +#loadmodule "extensions/ip_cloaking"; + +serverinfo { + name = "irc.localhost"; + sid = "42X"; + description = "charybdis test server"; + network_name = "StaticBox"; + + /* On multi-homed hosts you may need the following. These define + * the addresses we connect from to other servers. */ + /* for IPv4 */ + #vhost = "192.0.2.6"; + /* for IPv6 */ + #vhost6 = "2001:db8:2::6"; + + /* ssl_private_key: our ssl private key */ + ssl_private_key = "etc/ssl.key"; + + /* ssl_cert: certificate for our ssl server */ + ssl_cert = "etc/ssl.pem"; + + /* ssl_dh_params: DH parameters, generate with openssl dhparam -out dh.pem 2048 + * In general, the DH parameters size should be the same as your key's size. + * However it has been reported that some clients have broken TLS implementations which may + * choke on keysizes larger than 2048-bit, so we would recommend using 2048-bit DH parameters + * for now if your keys are larger than 2048-bit. + */ + ssl_dh_params = "etc/dh.pem"; + + /* ssld_count: number of ssld processes you want to start, if you + * have a really busy server, using N-1 where N is the number of + * cpu/cpu cores you have might be useful. A number greater than one + * can also be useful in case of bugs in ssld and because ssld needs + * two file descriptors per SSL connection. + */ + ssld_count = 1; + + /* default max clients: the default maximum number of clients + * allowed to connect. This can be changed once ircd has started by + * issuing: + * /quote set maxclients <limit> + */ + default_max_clients = 1024; + + /* nicklen: enforced nickname length (for this server only; must not + * be longer than the maximum length set while building). + */ + nicklen = 30; +}; + +admin { + name = "Lazy admin (lazya)"; + description = "StaticBox client server"; + email = "nobody@127.0.0.1"; +}; + +log { + fname_userlog = "logs/userlog"; + #fname_fuserlog = "logs/fuserlog"; + fname_operlog = "logs/operlog"; + #fname_foperlog = "logs/foperlog"; + fname_serverlog = "logs/serverlog"; + #fname_klinelog = "logs/klinelog"; + fname_killlog = "logs/killlog"; + fname_operspylog = "logs/operspylog"; + #fname_ioerrorlog = "logs/ioerror"; +}; + +/* class {} blocks MUST be specified before anything that uses them. That + * means they must be defined before auth {} and before connect {}. + */ +class "users" { + ping_time = 2 minutes; + number_per_ident = 10; + number_per_ip = 10; + number_per_ip_global = 50; + cidr_ipv4_bitlen = 24; + cidr_ipv6_bitlen = 64; + number_per_cidr = 200; + max_number = 3000; + sendq = 400 kbytes; +}; + +class "opers" { + ping_time = 5 minutes; + number_per_ip = 10; + max_number = 1000; + sendq = 1 megabyte; +}; + +class "server" { + ping_time = 5 minutes; + connectfreq = 5 minutes; + max_number = 1; + sendq = 4 megabytes; +}; + +listen { + /* defer_accept: wait for clients to send IRC handshake data before + * accepting them. if you intend to use software which depends on the + * server replying first, such as BOPM, you should disable this feature. + * otherwise, you probably want to leave it on. + */ + defer_accept = yes; + + /* If you want to listen on a specific IP only, specify host. + * host definitions apply only to the following port line. + */ + #host = "192.0.2.6"; + port = 5000, 6665 .. 6669; + # sslport = 6697; + + /* Listen on IPv6 (if you used host= above). */ + #host = "2001:db8:2::6"; + #port = 5000, 6665 .. 6669; + #sslport = 9999; +}; + +/* auth {}: allow users to connect to the ircd (OLD I:) + * auth {} blocks MUST be specified in order of precedence. The first one + * that matches a user will be used. So place spoofs first, then specials, + * then general access, then restricted. + */ +auth { + /* user: the user@host allowed to connect. Multiple IPv4/IPv6 user + * lines are permitted per auth block. This is matched against the + * hostname and IP address (using :: shortening for IPv6 and + * prepending a 0 if it starts with a colon) and can also use CIDR + * masks. + */ + user = "*@198.51.100.0/24"; + user = "*test@2001:db8:1:*"; + + /* password: an optional password that is required to use this block. + * By default this is not encrypted, specify the flag "encrypted" in + * flags = ...; below if it is. + */ + password = "letmein"; + + /* spoof: fake the users user@host to be be this. You may either + * specify a host or a user@host to spoof to. This is free-form, + * just do everyone a favour and dont abuse it. (OLD I: = flag) + */ + spoof = "I.still.hate.packets"; + + /* Possible flags in auth: + * + * encrypted | password is encrypted with mkpasswd + * spoof_notice | give a notice when spoofing hosts + * exceed_limit (old > flag) | allow user to exceed class user limits + * kline_exempt (old ^ flag) | exempt this user from k/g/xlines, + * | dnsbls, and proxies + * proxy_exempt | exempt this user from proxies + * dnsbl_exempt | exempt this user from dnsbls + * spambot_exempt | exempt this user from spambot checks + * shide_exempt | exempt this user from serverhiding + * jupe_exempt | exempt this user from generating + * warnings joining juped channels + * resv_exempt | exempt this user from resvs + * flood_exempt | exempt this user from flood limits + * USE WITH CAUTION. + * no_tilde (old - flag) | don't prefix ~ to username if no ident + * need_ident (old + flag) | require ident for user in this class + * need_ssl | require SSL/TLS for user in this class + * need_sasl | require SASL id for user in this class + */ + flags = kline_exempt, exceed_limit; + + /* class: the class the user is placed in */ + class = "opers"; +}; + +auth { + user = "*@*"; + class = "users"; +}; + +/* privset {} blocks MUST be specified before anything that uses them. That + * means they must be defined before operator {}. + */ +privset "local_op" { + privs = oper:local_kill, oper:operwall; +}; + +privset "server_bot" { + extends = "local_op"; + privs = oper:kline, oper:remoteban, snomask:nick_changes; +}; + +privset "global_op" { + extends = "local_op"; + privs = oper:global_kill, oper:routing, oper:kline, oper:unkline, oper:xline, + oper:resv, oper:mass_notice, oper:remoteban; +}; + +privset "admin" { + extends = "global_op"; + privs = oper:admin, oper:die, oper:rehash, oper:spy, oper:grant; +}; + +operator "god" { + /* name: the name of the oper must go above */ + + /* user: the user@host required for this operator. CIDR *is* + * supported now. auth{} spoofs work here, other spoofs do not. + * multiple user="" lines are supported. + */ + user = "*god@127.0.0.1"; + + /* password: the password required to oper. Unless ~encrypted is + * contained in flags = ...; this will need to be encrypted using + * mkpasswd, MD5 is supported + */ + password = "etcnjl8juSU1E"; + + /* rsa key: the public key for this oper when using Challenge. + * A password should not be defined when this is used, see + * doc/challenge.txt for more information. + */ + #rsa_public_key_file = "/usr/local/ircd/etc/oper.pub"; + + /* umodes: the specific umodes this oper gets when they oper. + * If this is specified an oper will not be given oper_umodes + * These are described above oper_only_umodes in general {}; + */ + #umodes = locops, servnotice, operwall, wallop; + + /* fingerprint: if specified, the oper's client certificate + * fingerprint will be checked against the specified fingerprint + * below. + */ + #fingerprint = "c77106576abf7f9f90cca0f63874a60f2e40a64b"; + + /* snomask: specific server notice mask on oper up. + * If this is specified an oper will not be given oper_snomask. + */ + snomask = "+Zbfkrsuy"; + + /* flags: misc options for the operator. You may prefix an option + * with ~ to disable it, e.g. ~encrypted. + * + * Default flags are encrypted. + * + * Available options: + * + * encrypted: the password above is encrypted [DEFAULT] + * need_ssl: must be using SSL/TLS to oper up + */ + flags = encrypted; + + /* privset: privileges set to grant */ + privset = "admin"; +}; + +connect "irc.uplink.com" { + host = "203.0.113.3"; + send_password = "password"; + accept_password = "anotherpassword"; + port = 6666; + hub_mask = "*"; + class = "server"; + flags = compressed, topicburst; + + #fingerprint = "c77106576abf7f9f90cca0f63874a60f2e40a64b"; + + /* If the connection is IPv6, uncomment below. + * Use 0::1, not ::1, for IPv6 localhost. */ + #aftype = ipv6; +}; + +connect "ssl.uplink.com" { + host = "203.0.113.129"; + send_password = "password"; + accept_password = "anotherpassword"; + port = 9999; + hub_mask = "*"; + class = "server"; + flags = ssl, topicburst; +}; + +service { + name = "services.int"; +}; + +cluster { + name = "*"; + flags = kline, tkline, unkline, xline, txline, unxline, resv, tresv, unresv; +}; + +shared { + oper = "*@*", "*"; + flags = all, rehash; +}; + +/* exempt {}: IPs that are exempt from Dlines and rejectcache. (OLD d:) */ +exempt { + ip = "127.0.0.1"; +}; + +channel { + use_invex = yes; + use_except = yes; + use_forward = yes; + use_knock = yes; + knock_delay = 5 minutes; + knock_delay_channel = 1 minute; + max_chans_per_user = 15; + max_chans_per_user_large = 60; + max_bans = 100; + max_bans_large = 500; + default_split_user_count = 0; + default_split_server_count = 0; + no_create_on_split = no; + no_join_on_split = no; + burst_topicwho = yes; + kick_on_split_riding = no; + only_ascii_channels = no; + resv_forcepart = yes; + channel_target_change = yes; + disable_local_channels = no; + autochanmodes = "+nt"; + displayed_usercount = 3; + strip_topic_colors = no; +}; + +serverhide { + flatten_links = yes; + links_delay = 5 minutes; + hidden = no; + disable_hidden = no; +}; + +alias "NickServ" { + target = "NickServ"; +}; + +alias "ChanServ" { + target = "ChanServ"; +}; + +alias "OperServ" { + target = "OperServ"; +}; + +alias "MemoServ" { + target = "MemoServ"; +}; + +alias "NS" { + target = "NickServ"; +}; + +alias "CS" { + target = "ChanServ"; +}; + +alias "OS" { + target = "OperServ"; +}; + +alias "MS" { + target = "MemoServ"; +}; + +general { + hide_error_messages = opers; + hide_spoof_ips = yes; + + /* + * default_umodes: umodes to enable on connect. + * If you have enabled the new ip_cloaking_4.0 module, and you want + * to make use of it, add +x to this option, i.e.: + * default_umodes = "+ix"; + * + * If you have enabled the old ip_cloaking module, and you want + * to make use of it, add +h to this option, i.e.: + * default_umodes = "+ih"; + */ + default_umodes = "+i"; + + default_operstring = "is an IRC Operator"; + default_adminstring = "is a Server Administrator"; + servicestring = "is a Network Service"; + + /* + * Nick of the network's SASL agent. Used to check whether services are here, + * SASL credentials are only sent to its server. Needs to be a service. + * + * Defaults to SaslServ if unspecified. + */ + sasl_service = "SaslServ"; + disable_fake_channels = no; + tkline_expire_notices = no; + default_floodcount = 10; + failed_oper_notice = yes; + dots_in_ident=2; + min_nonwildcard = 4; + min_nonwildcard_simple = 3; + max_accept = 100; + max_monitor = 100; + anti_nick_flood = yes; + max_nick_time = 20 seconds; + max_nick_changes = 5; + anti_spam_exit_message_time = 5 minutes; + ts_warn_delta = 30 seconds; + ts_max_delta = 5 minutes; + client_exit = yes; + collision_fnc = yes; + resv_fnc = yes; + global_snotices = yes; + dline_with_reason = yes; + kline_delay = 0 seconds; + kline_with_reason = yes; + kline_reason = "K-Lined"; + identify_service = "NickServ@services.int"; + identify_command = "IDENTIFY"; + non_redundant_klines = yes; + warn_no_nline = yes; + use_propagated_bans = yes; + stats_e_disabled = yes; + stats_c_oper_only=no; + stats_h_oper_only=no; + stats_y_oper_only=no; + stats_o_oper_only=yes; + stats_P_oper_only=no; + stats_i_oper_only=masked; + stats_k_oper_only=masked; + map_oper_only = no; + operspy_admin_only = no; + operspy_dont_care_user_info = no; + caller_id_wait = 1 minute; + pace_wait_simple = 1 second; + pace_wait = 10 seconds; + short_motd = no; + ping_cookie = no; + connect_timeout = 30 seconds; + default_ident_timeout = 5; + disable_auth = no; + no_oper_flood = yes; + max_targets = 4; + client_flood_max_lines = 20; + use_whois_actually = no; + oper_only_umodes = operwall, locops, servnotice; + oper_umodes = locops, servnotice, operwall, wallop; + oper_snomask = "+s"; + burst_away = yes; + nick_delay = 0 seconds; # 15 minutes if you want to enable this + reject_ban_time = 1 minute; + reject_after_count = 3; + reject_duration = 5 minutes; + throttle_duration = 60; + throttle_count = 4; + max_ratelimit_tokens = 30; + away_interval = 30; + certfp_method = sha1; + hide_opers_in_whois = no; +}; + +modules { + path = "modules"; + path = "modules/autoload"; +}; diff --git a/tests/iid.cpp b/tests/iid.cpp new file mode 100644 index 0000000..74d010d --- /dev/null +++ b/tests/iid.cpp @@ -0,0 +1,130 @@ +#include "catch.hpp" + +#include <irc/iid.hpp> +#include <irc/irc_user.hpp> + +#include <config/config.hpp> + +TEST_CASE("Irc user parsing") +{ + const std::map<char, char> prefixes{{'!', 'a'}, {'@', 'o'}}; + IrcUser user1("!nick!~some@host.bla", prefixes); + CHECK(user1.nick == "nick"); + CHECK(user1.host == "~some@host.bla"); + CHECK(user1.modes.size() == 1); + CHECK(user1.modes.find('a') != user1.modes.end()); + + IrcUser user2("coucou!~other@host.bla", prefixes); + CHECK(user2.nick == "coucou"); + CHECK(user2.host == "~other@host.bla"); + CHECK(user2.modes.empty()); + CHECK(user2.modes.find('a') == user2.modes.end()); +} + +TEST_CASE("multi-prefix") +{ + const std::map<char, char> prefixes{{'!', 'a'}, {'@', 'o'}, {'~', 'f'}}; + IrcUser user("!@~nick", prefixes); + CHECK(user.nick == "nick"); + CHECK(user.modes.size() == 3); + CHECK(user.modes.find('f') != user.modes.end()); +} + +/** + * Let Catch know how to display Iid objects + */ +namespace Catch +{ + template<> + struct StringMaker<Iid> + { + static std::string convert(const Iid& value) + { + return std::to_string(value); + } + }; +} + +TEST_CASE("Iid creation") +{ + Iid iid1("foo!irc.example.org"); + CHECK(std::to_string(iid1) == "foo!irc.example.org"); + CHECK(iid1.get_local() == "foo"); + CHECK(iid1.get_server() == "irc.example.org"); + CHECK(!iid1.is_channel); + CHECK(iid1.is_user); + + Iid iid2("#test%irc.example.org"); + CHECK(std::to_string(iid2) == "#test%irc.example.org"); + CHECK(iid2.get_local() == "#test"); + CHECK(iid2.get_server() == "irc.example.org"); + CHECK(iid2.is_channel); + CHECK(!iid2.is_user); + + Iid iid3("%irc.example.org"); + CHECK(std::to_string(iid3) == "%irc.example.org"); + CHECK(iid3.get_local() == ""); + CHECK(iid3.get_server() == "irc.example.org"); + CHECK(iid3.is_channel); + CHECK(!iid3.is_user); + + Iid iid4("irc.example.org"); + CHECK(std::to_string(iid4) == "irc.example.org"); + CHECK(iid4.get_local() == ""); + CHECK(iid4.get_server() == "irc.example.org"); + CHECK(!iid4.is_channel); + CHECK(!iid4.is_user); + + Iid iid5("nick!"); + CHECK(std::to_string(iid5) == "nick!"); + CHECK(iid5.get_local() == "nick"); + CHECK(iid5.get_server() == ""); + CHECK(!iid5.is_channel); + CHECK(iid5.is_user); + + Iid iid6("##channel%"); + CHECK(std::to_string(iid6) == "##channel%"); + CHECK(iid6.get_local() == "##channel"); + CHECK(iid6.get_server() == ""); + CHECK(iid6.is_channel); + CHECK(!iid6.is_user); +} + +TEST_CASE("Iid creation in fixed_server mode") +{ + Config::set("fixed_irc_server", "fixed.example.com", false); + + Iid iid1("foo!irc.example.org"); + CHECK(std::to_string(iid1) == "foo!"); + CHECK(iid1.get_local() == "foo"); + CHECK(iid1.get_server() == "fixed.example.com"); + CHECK(!iid1.is_channel); + CHECK(iid1.is_user); + + Iid iid2("#test%irc.example.org"); + CHECK(std::to_string(iid2) == "#test%irc.example.org"); + CHECK(iid2.get_local() == "#test%irc.example.org"); + CHECK(iid2.get_server() == "fixed.example.com"); + CHECK(iid2.is_channel); + CHECK(!iid2.is_user); + + // Note that it is impossible to adress the IRC server directly, or to + // use the virtual channel, in that mode + + // Iid iid3("%irc.example.org"); + // Iid iid4("irc.example.org"); + + Iid iid5("nick!"); + CHECK(std::to_string(iid5) == "nick!"); + CHECK(iid5.get_local() == "nick"); + CHECK(iid5.get_server() == "fixed.example.com"); + CHECK(!iid5.is_channel); + CHECK(iid5.is_user); + + Iid iid6("##channel%"); + CHECK(std::to_string(iid6) == "##channel%"); + CHECK(iid6.get_local() == "##channel%"); + CHECK(iid6.get_server() == "fixed.example.com"); + CHECK(iid6.is_channel); + CHECK(!iid6.is_user); +} diff --git a/tests/io_tester.cpp b/tests/io_tester.cpp new file mode 100644 index 0000000..19c97c9 --- /dev/null +++ b/tests/io_tester.cpp @@ -0,0 +1,30 @@ +#include "io_tester.hpp" +#include "catch.hpp" +#include <iostream> + +/** + * Directly test this class here + */ +TEST_CASE() +{ + { + IoTester<std::ostream> out(std::cout); + std::cout << "test"; + CHECK(out.str() == "test"); + } + { + IoTester<std::ostream> out(std::cout); + CHECK(out.str().empty()); + } +} + +TEST_CASE() +{ + { + IoTester<std::istream> is(std::cin); + is.set_string("coucou"); + std::string res; + std::cin >> res; + CHECK(res == "coucou"); + } +} diff --git a/tests/io_tester.hpp b/tests/io_tester.hpp new file mode 100644 index 0000000..b9cdaa7 --- /dev/null +++ b/tests/io_tester.hpp @@ -0,0 +1,45 @@ +#pragma once + +#include <ostream> +#include <sstream> + +/** + * Redirects a stream into a streambuf until the object is destroyed. + */ +template <typename StreamType> +class IoTester +{ +public: + IoTester(StreamType& ios): + stream{}, + ios(ios), + old_buf(ios.rdbuf()) + { + // Redirect the given os into our stringstream’s buf + this->ios.rdbuf(this->stream.rdbuf()); + } + ~IoTester() + { + this->ios.rdbuf(this->old_buf); + } + IoTester& operator=(const IoTester&) = delete; + IoTester& operator=(IoTester&&) = delete; + IoTester(const IoTester&) = delete; + IoTester(IoTester&&) = delete; + + std::string str() const + { + return this->stream.str(); + } + + void set_string(const std::string& s) + { + this->stream.str(s); + } + +private: + std::stringstream stream; + StreamType& ios; + std::streambuf* const old_buf; +}; + diff --git a/tests/jid.cpp b/tests/jid.cpp new file mode 100644 index 0000000..9015afd --- /dev/null +++ b/tests/jid.cpp @@ -0,0 +1,39 @@ +#include "catch.hpp" + +#include <xmpp/jid.hpp> +#include <louloulibs.h> + +TEST_CASE("Jid") +{ + Jid jid1("♥@ツ.coucou/coucou@coucou/coucou"); + CHECK(jid1.local == "♥"); + CHECK(jid1.domain == "ツ.coucou"); + CHECK(jid1.resource == "coucou@coucou/coucou"); + + // Domain and resource + Jid jid2("ツ.coucou/coucou@coucou/coucou"); + CHECK(jid2.local == ""); + CHECK(jid2.domain == "ツ.coucou"); + CHECK(jid2.resource == "coucou@coucou/coucou"); + + // Jidprep + const std::string badjid("~zigougou™@EpiK-7D9D1FDE.poez.io/Boujour/coucou/slt™"); + const std::string correctjid = jidprep(badjid); +#ifdef LIBIDN_FOUND + CHECK(correctjid == "~zigougoutm@epik-7d9d1fde.poez.io/Boujour/coucou/sltTM"); + // Check that the cache does not break things when we prep the same string + // multiple times + CHECK(jidprep(badjid) == "~zigougoutm@epik-7d9d1fde.poez.io/Boujour/coucou/sltTM"); + CHECK(jidprep(badjid) == "~zigougoutm@epik-7d9d1fde.poez.io/Boujour/coucou/sltTM"); + + const std::string badjid2("Zigougou@poez.io"); + const std::string correctjid2 = jidprep(badjid2); + CHECK(correctjid2 == "zigougou@poez.io"); + + const std::string crappy("~Bisous@7ea8beb1:c5fd2849:da9a048e:ip"); + const std::string fixed_crappy = jidprep(crappy); + CHECK(fixed_crappy == "~bisous@7ea8beb1-c5fd2849-da9a048e-ip"); +#else // Without libidn, jidprep always returns an empty string + CHECK(jidprep(badjid) == ""); +#endif +} diff --git a/tests/logger.cpp b/tests/logger.cpp new file mode 100644 index 0000000..1d59a22 --- /dev/null +++ b/tests/logger.cpp @@ -0,0 +1,57 @@ +#include "catch.hpp" + +#include <logger/logger.hpp> +#include <config/config.hpp> + +#include "io_tester.hpp" +#include <iostream> + +using namespace std::string_literals; + +TEST_CASE("Basic logging") +{ +#ifdef SYSTEMD_FOUND + const std::string debug_header = "<7>"; + const std::string error_header = "<3>"; +#else + const std::string debug_header = "[DEBUG]: "; + const std::string error_header = "[ERROR]: "; +#endif + Logger::instance().reset(); + GIVEN("A logger with log_level 0") + { + Config::set("log_level", "0"); + WHEN("we log some debug text") + { + IoTester<std::ostream> out(std::cout); + log_debug("deb", "ug"); + THEN("debug logs are written") + CHECK(out.str() == debug_header + "tests/logger.cpp:" + std::to_string(__LINE__ - 2) + ":\tdebug\n"); + } + WHEN("we log some errors") + { + IoTester<std::ostream> out(std::cout); + log_error("err", 12, "or"); + THEN("error logs are written") + CHECK(out.str() == error_header + "tests/logger.cpp:" + std::to_string(__LINE__ - 2) + ":\terr12or\n"); + } + } + GIVEN("A logger with log_level 3") + { + Config::set("log_level", "3"); + WHEN("we log some debug text") + { + IoTester<std::ostream> out(std::cout); + log_debug(123, "debug"); + THEN("nothing is written") + CHECK(out.str().empty()); + } + WHEN("we log some errors") + { + IoTester<std::ostream> out(std::cout); + log_error(123, " errors"); + THEN("error logs are still written") + CHECK(out.str() == error_header + "tests/logger.cpp:" + std::to_string(__LINE__ - 2) + ":\t123 errors\n"); + } + } +} diff --git a/tests/test.cpp b/tests/test.cpp new file mode 100644 index 0000000..0c7c351 --- /dev/null +++ b/tests/test.cpp @@ -0,0 +1,2 @@ +#define CATCH_CONFIG_MAIN +#include "catch.hpp" diff --git a/tests/timed_events.cpp b/tests/timed_events.cpp new file mode 100644 index 0000000..d63abef --- /dev/null +++ b/tests/timed_events.cpp @@ -0,0 +1,62 @@ +#include "catch.hpp" + +#include <utils/timed_events.hpp> + +/** + * Let Catch know how to display std::chrono::duration values + */ +namespace Catch +{ + template<typename Rep, typename Period> struct StringMaker<std::chrono::duration<Rep, Period>> + { + static std::string convert(const std::chrono::duration<Rep, Period>& value) + { + return std::to_string(std::chrono::duration_cast<std::chrono::milliseconds>(value).count()) + "ms"; + } + }; +} + +/** + * TODO, use a mock clock instead of relying on the real time with a sleep: + * it’s unreliable on heavy load. + */ +#include <thread> + +TEST_CASE("Test timed event expiration") +{ + SECTION("Check what happens when there is no events") + { + CHECK(TimedEventsManager::instance().get_timeout() == utils::no_timeout); + CHECK(TimedEventsManager::instance().execute_expired_events() == 0); + } + + // Add a single event + TimedEventsManager::instance().add_event(TimedEvent(std::chrono::steady_clock::now() + 50ms, [](){})); + + // The event should not yet be expired + CHECK(TimedEventsManager::instance().get_timeout() > 0ms); + CHECK(TimedEventsManager::instance().execute_expired_events() == 0); + + std::chrono::milliseconds timoute = TimedEventsManager::instance().get_timeout(); + INFO("Sleeping for " << timoute.count() << "ms"); + std::this_thread::sleep_for(timoute + 1ms); + + // Event is now expired + CHECK(TimedEventsManager::instance().execute_expired_events() == 1); + CHECK(TimedEventsManager::instance().get_timeout() == utils::no_timeout); +} + +TEST_CASE("Test timed event cancellation") +{ + auto now = std::chrono::steady_clock::now(); + TimedEventsManager::instance().add_event(TimedEvent(now + 100ms, [](){ }, "un")); + TimedEventsManager::instance().add_event(TimedEvent(now + 200ms, [](){ }, "deux")); + TimedEventsManager::instance().add_event(TimedEvent(now + 300ms, [](){ }, "deux")); + + CHECK(TimedEventsManager::instance().get_timeout() > 0ms); + CHECK(TimedEventsManager::instance().size() == 3); + CHECK(TimedEventsManager::instance().cancel("un") == 1); + CHECK(TimedEventsManager::instance().size() == 2); + CHECK(TimedEventsManager::instance().cancel("deux") == 2); + CHECK(TimedEventsManager::instance().get_timeout() == utils::no_timeout); +} diff --git a/tests/utils.cpp b/tests/utils.cpp new file mode 100644 index 0000000..01d070e --- /dev/null +++ b/tests/utils.cpp @@ -0,0 +1,102 @@ +#include "catch.hpp" + +#include <utils/tolower.hpp> +#include <utils/revstr.hpp> +#include <utils/string.hpp> +#include <utils/split.hpp> +#include <utils/xdg.hpp> +#include <utils/empty_if_fixed_server.hpp> + +TEST_CASE("String split") +{ + std::vector<std::string> splitted = utils::split("a::a", ':', false); + CHECK(splitted.size() == 2); + splitted = utils::split("a::a", ':', true); + CHECK(splitted.size() == 3); + CHECK(splitted[0] == "a"); + CHECK(splitted[1] == ""); + CHECK(splitted[2] == "a"); + splitted = utils::split("\na", '\n', true); + CHECK(splitted.size() == 2); + CHECK(splitted[0] == ""); + CHECK(splitted[1] == "a"); +} + +TEST_CASE("tolower") +{ + const std::string lowercase = utils::tolower("CoUcOu LeS CoPaiNs ♥"); + CHECK(lowercase == "coucou les copains ♥"); + + const std::string ltr = "coucou"; + CHECK(utils::revstr(ltr) == "uocuoc"); +} + +TEST_CASE("to_bool") +{ + CHECK(to_bool("true")); + CHECK(!to_bool("trou")); + CHECK(to_bool("1")); + CHECK(!to_bool("0")); + CHECK(!to_bool("-1")); + CHECK(!to_bool("false")); +} + +TEST_CASE("xdg_*_path") +{ + ::unsetenv("XDG_CONFIG_HOME"); + ::unsetenv("HOME"); + std::string res; + + SECTION("Without XDG_CONFIG_HOME nor HOME") + { + res = xdg_config_path("coucou.txt"); + CHECK(res == "coucou.txt"); + } + SECTION("With only HOME") + { + ::setenv("HOME", "/home/user", 1); + res = xdg_config_path("coucou.txt"); + CHECK(res == "/home/user/.config/biboumi/coucou.txt"); + } + SECTION("With only XDG_CONFIG_HOME") + { + ::setenv("XDG_CONFIG_HOME", "/some_weird_dir", 1); + res = xdg_config_path("coucou.txt"); + CHECK(res == "/some_weird_dir/biboumi/coucou.txt"); + } + SECTION("With XDG_DATA_HOME") + { + ::setenv("XDG_DATA_HOME", "/datadir", 1); + res = xdg_data_path("bonjour.txt"); + CHECK(res == "/datadir/biboumi/bonjour.txt"); + } +} + +TEST_CASE("empty if fixed irc server") +{ + GIVEN("A config with fixed_irc_server") + { + Config::set("fixed_irc_server", "irc.localhost"); + THEN("our string is made empty") + CHECK(utils::empty_if_fixed_server("coucou coucou") == ""); + } + GIVEN("A config with NO fixed_irc_server") + { + Config::set("fixed_irc_server", ""); + THEN("our string is returned untouched") + CHECK(utils::empty_if_fixed_server("coucou coucou") == "coucou coucou"); + } + +} + +TEST_CASE("string cut") +{ + CHECK(cut("coucou", 2).size() == 3); + CHECK(cut("bonjour les copains", 6).size() == 4); + CHECK(cut("««««", 2).size() == 4); + CHECK(cut("a««««", 2).size() == 5); + const auto res = cut("rhello, ♥", 10); + CHECK(res.size() == 2); + CHECK(res[0] == "rhello, "); + CHECK(res[1] == "♥"); +} diff --git a/tests/uuid.cpp b/tests/uuid.cpp new file mode 100644 index 0000000..12c6c32 --- /dev/null +++ b/tests/uuid.cpp @@ -0,0 +1,13 @@ +#include "catch.hpp" + +#include <xmpp/xmpp_component.hpp> + +TEST_CASE("id generation") +{ + const std::string first_uuid = XmppComponent::next_id(); + const std::string second_uuid = XmppComponent::next_id(); + + CHECK(first_uuid.size() == 36); + CHECK(second_uuid.size() == 36); + CHECK(first_uuid != second_uuid); +} diff --git a/tests/xmpp.cpp b/tests/xmpp.cpp new file mode 100644 index 0000000..6aab8c4 --- /dev/null +++ b/tests/xmpp.cpp @@ -0,0 +1,47 @@ +#include "catch.hpp" + +#include <xmpp/xmpp_parser.hpp> + +TEST_CASE("Test basic XML parsing") +{ + XmppParser xml; + + const std::string doc = "<stream xmlns='stream_ns'><stanza b='c'>inner<child1><grandchild/></child1><child2 xmlns='child2_ns'/>tail</stanza></stream>"; + + auto check_stanza = [](const Stanza& stanza) + { + CHECK(stanza.get_name() == "stanza"); + CHECK(stanza.get_tag("xmlns") == "stream_ns"); + CHECK(stanza.get_tag("b") == "c"); + CHECK(stanza.get_inner() == "inner"); + CHECK(stanza.get_tail() == ""); + CHECK(stanza.get_child("child1", "stream_ns") != nullptr); + CHECK(stanza.get_child("child2", "stream_ns") == nullptr); + CHECK(stanza.get_child("child2", "child2_ns") != nullptr); + CHECK(stanza.get_child("child2", "child2_ns")->get_tail() == "tail"); + }; + xml.add_stanza_callback([check_stanza](const Stanza& stanza) + { + check_stanza(stanza); + // Do the same checks on a copy of that stanza. + Stanza copy(stanza); + check_stanza(copy); + // And do the same checks on moved-constructed stanza + Stanza moved(std::move(copy)); + }); + xml.feed(doc.data(), doc.size(), true); + + const std::string doc2 = "<stream xmlns='s'><stanza>coucou\r\n\a</stanza></stream>"; + xml.add_stanza_callback([](const Stanza& stanza) + { + CHECK(stanza.get_inner() == "coucou\r\n"); + }); + + xml.feed(doc2.data(), doc.size(), true); +} + +TEST_CASE("XML escape") +{ + const std::string unescaped = "'coucou'<cc>/&\"gaga\""; + CHECK(xml_escape(unescaped) == "'coucou'<cc>/&"gaga""); +} diff --git a/unit/biboumi.service.cmake b/unit/biboumi.service.cmake new file mode 100644 index 0000000..150045b --- /dev/null +++ b/unit/biboumi.service.cmake @@ -0,0 +1,16 @@ +[Unit] +Description=Biboumi, XMPP to IRC gateway +Documentation=man:biboumi(1) https://biboumi.louiz.org +After=network.target + +[Service] +Type=${SYSTEMD_SERVICE_TYPE} +ExecStart=${CMAKE_INSTALL_PREFIX}/bin/biboumi /etc/biboumi/biboumi.cfg +ExecReload=/bin/kill -s USR1 $MAINPID +WatchdogSec=${WATCHDOG_SEC} +Restart=always +User=${SERVICE_USER} +Group=${SERVICE_GROUP} + +[Install] +WantedBy=multi-user.target |