From c464265f60ddd367786b08f5d49cd7a6d650b7d6 Mon Sep 17 00:00:00 2001 From: Roland Reichwein Date: Sat, 21 Jan 2023 19:05:43 +0100 Subject: First websocket connection --- Makefile | 10 +- common.mk | 2 +- config.cpp | 37 ++++- config.h | 8 + debian/README.Debian | 4 +- debian/changelog | 6 + debian/control | 4 +- debian/whiteboard.whiteboard.service | 2 +- start.sh | 5 - tests/test-whiteboard.cpp | 120 ++++++++++++++- webserver.conf.example | 4 +- whiteboard.conf | 12 ++ whiteboard.cpp | 286 ++++++++++++++++++++--------------- whiteboard.h | 10 +- 14 files changed, 369 insertions(+), 141 deletions(-) delete mode 100755 start.sh diff --git a/Makefile b/Makefile index 6dba940..cb5ef3e 100755 --- a/Makefile +++ b/Makefile @@ -16,7 +16,7 @@ INCLUDES=-I. HEADERS=config.h qrcode.h storage.h whiteboard.h compiledsql.h SOURCES=$(HEADERS:.h=.cpp) OBJECTS=$(HEADERS:.h=.o) -TARGETS=whiteboard.fcgi +TARGETS=whiteboard build: $(TARGETS) @@ -24,8 +24,8 @@ all: build ./start.sh install: - mkdir -p $(DESTDIR)/usr/lib/whiteboard - cp whiteboard.fcgi $(DESTDIR)/usr/lib/whiteboard/ + mkdir -p $(DESTDIR)/usr/bin + cp whiteboard $(DESTDIR)/usr/bin mkdir -p $(DESTDIR)/usr/lib/whiteboard/html cp -r html/* $(DESTDIR)/usr/lib/whiteboard/html/ @@ -38,7 +38,7 @@ install: cp whiteboard.conf $(DESTDIR)/etc # link -whiteboard.fcgi: $(OBJECTS) main.o +whiteboard: $(OBJECTS) main.o $(CXX) $(LDFLAGS) $^ $(LDLIBS) $(LIBS) -o $@ # .cpp -> .o @@ -49,7 +49,7 @@ test: $(MAKE) -C tests clean: - -rm -f *.o *.fcgi *.gcov + -rm -f *.o $(TARGETS) *.gcov $(MAKE) -C tests clean deb: diff --git a/common.mk b/common.mk index 2dba055..1f4c2df 100644 --- a/common.mk +++ b/common.mk @@ -57,7 +57,7 @@ CXXTYPE=g++ endif CXXFLAGS+=$(shell pkg-config --cflags qrcodegencpp GraphicsMagick++ fmt sqlite3) -LIBS+=-lfcgi -lboost_filesystem -lpthread +LIBS+=-lboost_filesystem -lpthread LIBS+=-lSQLiteCpp $(shell pkg-config --libs qrcodegencpp GraphicsMagick++ fmt sqlite3) LIBS+=-lreichwein diff --git a/config.cpp b/config.cpp index 4488e84..d59156f 100644 --- a/config.cpp +++ b/config.cpp @@ -4,15 +4,24 @@ #include #include +#include namespace pt = boost::property_tree; +using namespace std::string_literals; namespace { const std::string default_datapath {"/var/lib/whiteboard"}; const uint64_t default_maxage{0}; // timeout in seconds; 0 = no timeout + const std::string default_listen {"::1:9000"}; + const int default_threads{1}; } -Config::Config(const std::string& config_filename): m_dataPath{default_datapath}, m_maxage{default_maxage} +Config::Config(const std::string& config_filename): + m_dataPath{default_datapath}, + m_maxage{default_maxage}, + m_listenAddress{"::1"}, + m_listenPort{9000}, + m_threads{default_threads} { try { @@ -22,6 +31,17 @@ Config::Config(const std::string& config_filename): m_dataPath{default_datapath} m_dataPath = tree.get("config.datapath", default_datapath); m_maxage = tree.get("config.maxage", default_maxage); + std::string listen {tree.get("config.port", default_listen)}; + auto pos{listen.find_last_of(':')}; + if (pos == std::string::npos) + throw std::runtime_error("Bad port address: "s + listen); + + m_listenAddress = listen.substr(0, pos); + m_listenPort = std::stoi(listen.substr(pos + 1)); + if (m_listenPort < 0 || m_listenPort > 65535) + throw std::runtime_error("Bad listen port: "s + std::to_string(m_listenPort)); + + m_threads = tree.get("config.threads", default_threads); } catch (const std::exception& ex) { std::cerr << "Error reading config file " << config_filename << ". Using defaults." << std::endl; } @@ -36,3 +56,18 @@ uint64_t Config::getMaxage() const { return m_maxage; } + +std::string Config::getListenAddress() const +{ + return m_listenAddress; +} + +int Config::getListenPort() const +{ + return m_listenPort; +} + +int Config::getThreads() const +{ + return m_threads; +} diff --git a/config.h b/config.h index 4f589ff..01310aa 100644 --- a/config.h +++ b/config.h @@ -9,9 +9,17 @@ class Config private: std::string m_dataPath; uint64_t m_maxage; + std::string m_listenAddress; // ip address v4/v6 + int m_listenPort; + int m_threads; public: Config(const std::string& config_filename = default_config_filename); std::string getDataPath() const; uint64_t getMaxage() const; + + std::string getListenAddress() const; + int getListenPort() const; + + int getThreads() const; }; diff --git a/debian/README.Debian b/debian/README.Debian index 07ab4d6..c740e12 100644 --- a/debian/README.Debian +++ b/debian/README.Debian @@ -17,8 +17,8 @@ Configuration static-files /usr/lib/whiteboard/html - - fcgi + + websocket 127.0.0.1:9014 diff --git a/debian/changelog b/debian/changelog index 0b9c82c..8373568 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,9 @@ +whiteboard (1.5) UNRELEASED; urgency=medium + + * Move from FCGI to websocket interface + + -- Roland Reichwein Sat, 21 Jan 2023 18:18:37 +0100 + whiteboard (1.4) unstable; urgency=medium * Move from filesystem based documents to sqlite diff --git a/debian/control b/debian/control index 6b0db0e..0dce8fc 100644 --- a/debian/control +++ b/debian/control @@ -2,13 +2,13 @@ Source: whiteboard Section: web Priority: optional Maintainer: Roland Reichwein -Build-Depends: debhelper (>= 12), libboost-all-dev | libboost1.71-all-dev, clang | g++-9, llvm | g++-9, lld | g++-9, uglifyjs, python3-pkg-resources, htmlmin, cleancss, libfcgi-dev, libqrcodegencpp-dev, libgraphicsmagick++-dev, pkg-config, libfmt-dev, libsqlitecpp-dev, googletest, gcovr, webserver, libreichwein-dev +Build-Depends: debhelper (>= 12), libboost-all-dev | libboost1.71-all-dev, clang | g++-9, llvm | g++-9, lld | g++-9, uglifyjs, python3-pkg-resources, htmlmin, cleancss, libqrcodegencpp-dev, libgraphicsmagick++-dev, pkg-config, libfmt-dev, libsqlitecpp-dev, googletest, gcovr, webserver, libreichwein-dev Standards-Version: 4.5.0 Homepage: http://www.reichwein.it/whiteboard/ Package: whiteboard Architecture: any -Depends: ${shlibs:Depends}, ${misc:Depends}, spawn-fcgi, libxml2-utils +Depends: ${shlibs:Depends}, ${misc:Depends}, libxml2-utils Recommends: webserver Homepage: http://www.reichwein.it/whiteboard/ Description: Web application for an collaborative editor diff --git a/debian/whiteboard.whiteboard.service b/debian/whiteboard.whiteboard.service index c60f3f0..b41e655 100644 --- a/debian/whiteboard.whiteboard.service +++ b/debian/whiteboard.whiteboard.service @@ -5,7 +5,7 @@ After=network.target [Service] Type=simple # Restart=always -ExecStart=spawn-fcgi -a 127.0.0.1 -p 9014 -n -- /usr/lib/whiteboard/whiteboard.fcgi +ExecStart=/usr/bin/whiteboard Restart=always diff --git a/start.sh b/start.sh deleted file mode 100755 index b42c33e..0000000 --- a/start.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/bash -# -# Test script for debugging -# -spawn-fcgi -a 127.0.0.1 -p 9014 -n -- ./whiteboard.fcgi diff --git a/tests/test-whiteboard.cpp b/tests/test-whiteboard.cpp index 46c4bae..b183021 100644 --- a/tests/test-whiteboard.cpp +++ b/tests/test-whiteboard.cpp @@ -1,21 +1,39 @@ #include +#include #include #include #include #include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + #include "libreichwein/file.h" +#include "libreichwein/process.h" #include "config.h" #include "storage.h" #include "whiteboard.h" +namespace bp = boost::process; namespace fs = std::filesystem; using namespace Reichwein; +using namespace std::string_literals; namespace { const fs::path webserverConfigFilename{"./webserver.conf"}; + const fs::path testConfigFilename{"./whiteboard.conf"}; const fs::path testDbFilename{"./whiteboard.db3"}; } @@ -25,20 +43,55 @@ class Webserver public: Webserver() { + File::setFile(webserverConfigFilename, R"CONFIG( + + www-data + www-data + 10 + stats.db + ../plugins + + + [::1] + + websocket + ::1:9876 + + + + + +
::1
+ 8080 + http + localhost +
+
+
+)CONFIG"); + start(); } ~Webserver() { stop(); + fs::remove(webserverConfigFilename); } - void runInBackground() + void start() { + m_child = bp::child("/usr/bin/webserver"s, "-c"s, webserverConfigFilename.generic_string()); + Process::wait_for_pid_listening_on(m_child.id(), 8080); + std::this_thread::sleep_for(std::chrono::milliseconds(20)); } void stop() { + m_child.terminate(); } + +private: + bp::child m_child; }; class WhiteboardTest: public ::testing::Test @@ -54,27 +107,92 @@ protected: { File::setFile(testConfigFilename, R"CONFIG( + ::1:9876 . 2592000 + 4 )CONFIG"); std::error_code ec; fs::remove(testDbFilename, ec); m_config = std::make_shared(testConfigFilename); + + //m_webserver = std::make_shared(webserverConfigFilename); + + m_pid = fork(); + if (m_pid == -1) { + throw std::runtime_error("Error on fork(): "s + strerror(errno)); + } else if (m_pid == 0) { // child + Whiteboard whiteboard; + std::vector argvv{{"whiteboard", "-c", testConfigFilename.generic_string()}}; + char* argv[] = {argvv[0].data(), argvv[1].data(), argvv[2].data(), nullptr}; + whiteboard.run(argvv.size(), argv); + exit(0); + } + Process::wait_for_pid_listening_on(m_pid, 9876); + std::this_thread::sleep_for(std::chrono::milliseconds(20)); } void TearDown() override { + if (m_pid == 0) + throw std::runtime_error("Whiteboard not running on requesting SIGTERM"); + + if (kill(m_pid, SIGTERM) != 0) + throw std::runtime_error("Unable to SIGTERM Whiteboard"); + + if (int result = waitpid(m_pid, NULL, 0); result != m_pid) + throw std::runtime_error("waitpid returned "s + std::to_string(result)); + std::error_code ec; fs::remove(testDbFilename, ec); fs::remove(testConfigFilename, ec); } std::shared_ptr m_config; + //std::shared_ptr m_webserver; + pid_t m_pid{}; }; TEST_F(WhiteboardTest, connection) +{ + std::string host = "::1"; + auto const port = "9876" ; + + // The io_context is required for all I/O + boost::asio::io_context ioc; + + // These objects perform our I/O + boost::asio::ip::tcp::resolver resolver{ioc}; + boost::beast::websocket::stream ws{ioc}; + + // Look up the domain name + auto const results = resolver.resolve(host, port); + + // Make the connection on the IP address we get from a lookup + auto ep = boost::asio::connect(boost::beast::get_lowest_layer(ws), results); + + // Update the host_ string. This will provide the value of the + // Host HTTP header during the WebSocket handshake. + // See https://tools.ietf.org/html/rfc7230#section-5.4 + if (host == "::1") + host = "[" + host + "]"; + host += ':' + std::to_string(ep.port()); + + // Set a decorator to change the User-Agent of the handshake + ws.set_option(boost::beast::websocket::stream_base::decorator( + [](boost::beast::websocket::request_type& req) + { + req.set(boost::beast::http::field::user_agent, + std::string("Reichwein.IT Test Websocket Client")); + })); + + // Perform the websocket handshake + ws.handshake(host, "/"); +} + +TEST_F(WhiteboardTest, getfile) { } diff --git a/webserver.conf.example b/webserver.conf.example index eeb48a1..9193a5b 100644 --- a/webserver.conf.example +++ b/webserver.conf.example @@ -2,7 +2,7 @@ static-files /usr/lib/whiteboard/html
- - fcgi + + websocket 127.0.0.1:9014 diff --git a/whiteboard.conf b/whiteboard.conf index 80ae173..126bef5 100644 --- a/whiteboard.conf +++ b/whiteboard.conf @@ -5,10 +5,22 @@ --> /var/lib/whiteboard + + ::1:8765 + 2592000 + + + 4 diff --git a/whiteboard.cpp b/whiteboard.cpp index 6466635..be8bcb0 100644 --- a/whiteboard.cpp +++ b/whiteboard.cpp @@ -7,8 +7,6 @@ #include #include -#include - #include #include #include @@ -22,6 +20,15 @@ #include #include #include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include "libreichwein/file.h" @@ -38,20 +45,20 @@ namespace { void usage() { std::cout << "Usage: \n" - " whiteboard [-c]\n" + " whiteboard [options]\n" "\n" "Options:\n" - " -c : Cleanup database according to timeout rules (config: maxage)\n" + " -c : specify configuration file including path\n" + " -C : clean up database according to timeout rules (config: maxage)\n" + " -h : this help\n" "\n" - "Without options, whiteboard will be started as FCGI application" + "Without options, whiteboard will be started as websocket application" << std::endl; } } // namespace -Whiteboard::Whiteboard(): - m_config(), - m_storage(m_config) +Whiteboard::Whiteboard() { } @@ -61,136 +68,177 @@ void Whiteboard::storage_cleanup() while(true) { { std::lock_guard lock(m_storage_mutex); - m_storage.cleanup(); + if (!m_storage) + throw std::runtime_error("Storage not initialized"); + m_storage->cleanup(); } std::this_thread::sleep_for(std::chrono::minutes(10)); } } +std::string Whiteboard::handle_request(const std::string& request) +{ + try { + std::lock_guard lock(m_storage_mutex); + if (!m_storage) + throw std::runtime_error("Storage not initialized"); + + pt::ptree xml; + std::istringstream ss{request}; + pt::xml_parser::read_xml(ss, xml); + + std::string command {xml.get("request.command")}; + + if (command == "modify") { + std::string id {xml.get("request.id")}; + std::string data {xml.get("request.data")}; + m_storage->setDocument(id, data); + return {}; + } else if (command == "getfile") { + std::string id {xml.get("request.id")}; + + std::string filedata {m_storage->getDocument(id)}; + + if (filedata.size() > 30000000) + throw std::runtime_error("File too big"); + + return filedata; + } else if (command == "checkupdate") { + std::string id {xml.get("request.id")}; + std::string checksum_s {xml.get("request.checksum")}; + uint32_t checksum{static_cast(stoul(checksum_s))}; + + std::string filedata {m_storage->getDocument(id)}; + if (checksum != m_storage->checksum32(filedata)) { + return filedata; + } else { + return {}; + } + } else if (command == "newid") { + return m_storage->generate_id(); + } else if (command == "qrcode") { + std::string url{xml.get("request.url")}; + + if (url.size() > 1000) + throw std::runtime_error("URL too big"); + + std::string pngdata {QRCode::getQRCode(url)}; + + return pngdata; + } else { + throw std::runtime_error("Bad command: "s + command); + } + + } catch (const std::exception& ex) { + return "Message handling error: "s + ex.what(); + } +} + +void Whiteboard::do_session(boost::asio::ip::tcp::socket socket) +{ + try { + // Construct the stream by moving in the socket + boost::beast::websocket::stream ws{std::move(socket)}; + + // Set a decorator to change the Server of the handshake + ws.set_option(boost::beast::websocket::stream_base::decorator( + [](boost::beast::websocket::response_type& res) + { + res.set(boost::beast::http::field::server, + std::string("Reichwein.IT Whiteboard")); + })); + + boost::beast::http::request_parser parser; + boost::beast::http::request req; + boost::beast::flat_buffer buffer; + + boost::beast::http::read(ws.next_layer(), buffer, parser); + req = parser.get(); + + ws.accept(req); + + while (true) { + boost::beast::flat_buffer buffer; + + ws.read(buffer); + + ws.text(ws.got_text()); + std::string data(boost::asio::buffers_begin(buffer.data()), boost::asio::buffers_end(buffer.data())); + data = handle_request(data); + buffer.consume(buffer.size()); + boost::beast::ostream(buffer) << data; + ws.write(buffer.data()); + } + } catch (boost::beast::system_error const& se) { + // This indicates that the session was closed + if (se.code() != boost::beast::websocket::error::closed) + std::cerr << "Boost system_error in session: " << se.code().message() << std::endl; + } catch (std::exception const& ex) { + std::cerr << "Error in session: " << ex.what() << std::endl; + } +} + // the actual main() for testability int Whiteboard::run(int argc, char* argv[]) { - if (argc == 2) { - if (argv[1] == "-h"s || argv[1] == "-?"s) { - usage(); - exit(0); - } else if (argv[1] == "-c"s) { - m_storage.cleanup(); + try { + bool flag_cleanup{}; + fs::path configFile; + + if (argc == 2) { + if (argv[1] == "-h"s || argv[1] == "-?"s) { + usage(); + exit(0); + } else if (argv[1] == "-C"s) { + flag_cleanup = true; + } + } else if (argc == 3) { + if (argv[1] == "-c"s) { + configFile = argv[2]; + } + } + + if (configFile.empty()) + m_config = std::make_unique(); + else + m_config = std::make_unique(configFile); + + m_storage = std::make_unique(*m_config); + + if (flag_cleanup) { + m_storage->cleanup(); exit(0); } - } - std::thread storage_cleanup_thread(std::bind(&Whiteboard::storage_cleanup, this)); + std::thread storage_cleanup_thread(std::bind(&Whiteboard::storage_cleanup, this)); - QRCode::init(); + QRCode::init(); - int result = FCGX_Init(); - if (result != 0) { // error on init - fprintf(stderr, "Error: FCGX_Init()\n"); - return 1; - } + auto const address = boost::asio::ip::make_address(m_config->getListenAddress()); + auto const port = static_cast(m_config->getListenPort()); - result = FCGX_IsCGI(); - if (result) { - fprintf(stderr, "Error: No FCGI environment available.\n"); - return 1; - } + // The io_context is required for all I/O + boost::asio::io_context ioc{m_config->getThreads()}; - FCGX_Request request; - result = FCGX_InitRequest(&request, 0, 0); - if (result != 0) { - fprintf(stderr, "Error: FCGX_InitRequest()\n"); - return 1; - } + // The acceptor receives incoming connections + boost::asio::ip::tcp::acceptor acceptor{ioc, {address, port}}; + while (true) { + // This will receive the new connection + boost::asio::ip::tcp::socket socket{ioc}; - while (FCGX_Accept_r(&request) >= 0) { - try { - std::lock_guard lock(m_storage_mutex); - char* method = FCGX_GetParam("REQUEST_METHOD", request.envp); - - // POST for server actions, changes - if (!strcmp(method, "POST")) { - size_t contentLength { std::stoul(FCGX_GetParam("CONTENT_LENGTH", request.envp)) }; - std::string postData(contentLength, '\0'); // contentLength number of bytes, initialize with 0 - if (FCGX_GetStr(postData.data(), contentLength, request.in) != static_cast(contentLength)) { - throw std::runtime_error("Bad data read: Content length mismatch.\r\n"); - } - // postData contains POST data - std::string contentType(FCGX_GetParam("CONTENT_TYPE", request.envp)); - - std::string xmlData = postData; // default: interpret whole POST data as xml request - - pt::ptree xml; - std::istringstream ss{xmlData}; - pt::xml_parser::read_xml(ss, xml); - - std::string command {xml.get("request.command")}; - - if (command == "modify") { - std::string id {xml.get("request.id")}; - std::string data {xml.get("request.data")}; - m_storage.setDocument(id, data); - FCGX_PutS("Content-Type: text/plain\r\n\r\n", request.out); - } else if (command == "getfile") { - std::string id {xml.get("request.id")}; - - std::string filedata {m_storage.getDocument(id)}; - - if (filedata.size() > 30000000) - throw std::runtime_error("File too big"); - - FCGX_PutS("Content-Type: application/octet-stream\r\n", request.out); - FCGX_FPrintF(request.out, "Content-Length: %d\r\n\r\n", filedata.size()); - FCGX_PutStr(filedata.c_str(), filedata.size(), request.out); - } else if (command == "checkupdate") { - std::string id {xml.get("request.id")}; - std::string checksum_s {xml.get("request.checksum")}; - uint32_t checksum{static_cast(stoul(checksum_s))}; - - std::string filedata {m_storage.getDocument(id)}; - if (checksum != m_storage.checksum32(filedata)) { - //std::cout << "Sending change..." << std::endl; - FCGX_PutS("Content-Type: application/octet-stream\r\n", request.out); - FCGX_FPrintF(request.out, "Content-Length: %d\r\n\r\n", filedata.size()); - FCGX_PutStr(filedata.c_str(), filedata.size(), request.out); - } else { - //std::cout << "No change..." << std::endl; - FCGX_PutS("Content-Type: text/plain\r\n\r\n", request.out); - FCGX_PutS("No change.\r\n", request.out); - } - } else if (command == "newid") { - FCGX_PutS("Content-Type: text/plain\r\n\r\n", request.out); - FCGX_PutS(m_storage.generate_id().c_str(), request.out); - } else if (command == "qrcode") { - std::string url{xml.get("request.url")}; - - if (url.size() > 1000) - throw std::runtime_error("URL too big"); - - std::string pngdata {QRCode::getQRCode(url)}; - - FCGX_PutS("Content-Type: image/png\r\n", request.out); - FCGX_FPrintF(request.out, "Content-Length: %d\r\n\r\n", pngdata.size()); - FCGX_PutStr(pngdata.c_str(), pngdata.size(), request.out); - } else { - throw std::runtime_error("Bad command: "s + command); - } + // Block until we get a connection + acceptor.accept(socket); - } else { - throw std::runtime_error("Unsupported method.\r\n"); - } - } catch (const std::runtime_error& ex) { - FCGX_PutS("Status: 500 Internal Server Error\r\n", request.out); - FCGX_PutS("Content-Type: text/html\r\n\r\n", request.out); - FCGX_FPrintF(request.out, "Error: %s\r\n", ex.what()); - } catch (const std::exception& ex) { - FCGX_PutS("Status: 500 Internal Server Error\r\n", request.out); - FCGX_PutS("Content-Type: text/html\r\n\r\n", request.out); - FCGX_FPrintF(request.out, "Unknown exception: %s\r\n", ex.what()); + // Launch the session, transferring ownership of the socket + std::thread( + &Whiteboard::do_session, this, + std::move(socket)).detach(); } - } - storage_cleanup_thread.join(); + storage_cleanup_thread.join(); + } catch (const std::exception& ex) { + std::cerr << "Error: " << ex.what() << std::endl; + } return 0; } diff --git a/whiteboard.h b/whiteboard.h index f50f455..d645115 100644 --- a/whiteboard.h +++ b/whiteboard.h @@ -1,8 +1,12 @@ #pragma once +#include +#include #include #include +#include + #include "config.h" #include "storage.h" @@ -13,10 +17,12 @@ public: int run(int argc, char* argv[]); private: - Config m_config; - Storage m_storage; + std::unique_ptr m_config; + std::unique_ptr m_storage; std::mutex m_storage_mutex; + std::string handle_request(const std::string& request); + void do_session(boost::asio::ip::tcp::socket socket); void storage_cleanup(); }; -- cgit v1.2.3