From d2c09307b73c12ae9c50c48b71d38462b5dcf77f Mon Sep 17 00:00:00 2001 From: Catink123 Date: Sat, 6 Jan 2024 03:03:08 +0300 Subject: [PATCH] add a basic http server to host the client app, add README and launch.json for vscode debugging --- .vscode/launch.json | 24 ++++ CMakeLists.txt | 5 + README.md | 42 +++++++ src/client/index.html | 11 ++ src/common.hpp | 16 +++ src/http_listener.cpp | 66 ++++++++++ src/http_listener.hpp | 29 +++++ src/http_session.cpp | 287 ++++++++++++++++++++++++++++++++++++++++++ src/http_session.hpp | 58 +++++++++ src/main.cpp | 33 ++++- 10 files changed, 570 insertions(+), 1 deletion(-) create mode 100644 .vscode/launch.json create mode 100644 README.md create mode 100644 src/client/index.html create mode 100644 src/common.hpp create mode 100644 src/http_listener.cpp create mode 100644 src/http_listener.hpp create mode 100644 src/http_session.cpp create mode 100644 src/http_session.hpp diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..75c08d6 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,24 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "(msvc) Launch", + "type": "cppvsdbg", + "request": "launch", + // Resolved by CMake Tools: + "program": "${command:cmake.launchTargetPath}", + "args": [], + "stopAtEntry": false, + "cwd": "${workspaceFolder}", + "environment": [ + { + // add the directory where our target was built to the PATHs + // it gets resolved by CMake Tools: + "name": "PATH", + "value": "${env:PATH}:${command:cmake.getLaunchTargetDirectory}" + } + ], + "console": "integratedTerminal" + } + ] +} \ No newline at end of file diff --git a/CMakeLists.txt b/CMakeLists.txt index 11e189b..7b4120d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -4,7 +4,12 @@ set(GATE_CONTROL_PROJECT_NAME GateControl) set(GATE_CONTROL_SOURCE_DIR src) set(GATE_CONTROL_SOURCE + ${GATE_CONTROL_SOURCE_DIR}/common.hpp ${GATE_CONTROL_SOURCE_DIR}/main.cpp + ${GATE_CONTROL_SOURCE_DIR}/http_listener.hpp + ${GATE_CONTROL_SOURCE_DIR}/http_listener.cpp + ${GATE_CONTROL_SOURCE_DIR}/http_session.hpp + ${GATE_CONTROL_SOURCE_DIR}/http_session.cpp ) include("cmake/modules/CPM.cmake") diff --git a/README.md b/README.md new file mode 100644 index 0000000..87c99c0 --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +# Gate Control + +Gate Control is a client-server application that allows control of an Arduino microcontroller over the local network. + +## Building + +There are two ways of getting built server binaries: manual build and release build. + +### Manual build + +First, clone the repo to your local machine: + +```sh +$ git clone https://github.com/catink123/gate-control +$ cd gate-control +``` + +Then, run CMake (version >= 3.14) in the repo's root directory with one of the provided presets: + +```sh +$ cmake --build --preset +``` + +Substitute `` with the chosen preset. Available presets can be obtained using the following command: + +```sh +$ cmake --list-presets +``` + +After CMake successfully builds the server, the binaries should be located in the `out/` directory. + +### Release build + +You can obtain prebuilt binaries from the [Releases tab](https://github.com/catink123/gate-control/releases). + +## Architecture + +The server acts as a bridge between a user on the network and the USB-connected Arduino microcontroller. + +The server hosts a client application, available at it's address. A user can enter the server's address in the web browser and access the remote control panel for the microcontroller. + +Arduino microcontroller acts as a gate control, or in other words it holds information about the physical state of the gate and allows to change that state (raise or lower the gate). \ No newline at end of file diff --git a/src/client/index.html b/src/client/index.html new file mode 100644 index 0000000..2aa990e --- /dev/null +++ b/src/client/index.html @@ -0,0 +1,11 @@ + + + + + + Gate Control + + +

Test Client Page

+ + \ No newline at end of file diff --git a/src/common.hpp b/src/common.hpp new file mode 100644 index 0000000..5770d17 --- /dev/null +++ b/src/common.hpp @@ -0,0 +1,16 @@ +#ifndef COMMON_HPP +#define COMMON_HPP + +#include +#include +#include +#include +#include +#include + +namespace beast = boost::beast; +namespace http = beast::http; +namespace net = boost::beast::net; +using tcp = net::ip::tcp; + +#endif \ No newline at end of file diff --git a/src/http_listener.cpp b/src/http_listener.cpp new file mode 100644 index 0000000..dc782e9 --- /dev/null +++ b/src/http_listener.cpp @@ -0,0 +1,66 @@ +#include "http_listener.hpp" + +http_listener::http_listener( + net::io_context& ioc, + tcp::endpoint endpoint, + const std::shared_ptr& doc_root +) : ioc(ioc), + acceptor(net::make_strand(ioc)), + doc_root(doc_root) +{ + beast::error_code ec; + + acceptor.open(endpoint.protocol(), ec); + if (ec) { + std::cerr << "Couldn't open acceptor: " << ec.message() << std::endl; + return; + } + + acceptor.set_option(net::socket_base::reuse_address(true), ec); + if (ec) { + std::cerr << "Couldn't set reuse_address: " << ec.message() << std::endl; + return; + } + + acceptor.bind(endpoint, ec); + if (ec) { + std::cerr << "Couldn't bind to endpoint: " << ec.message() << std::endl; + return; + } + + acceptor.listen( + net::socket_base::max_listen_connections, ec + ); + if (ec) { + std::cerr << "Couldn't start listening: " << ec.message() << std::endl; + return; + } +} + +void http_listener::run() { + do_accept(); +} + +void http_listener::do_accept() { + acceptor.async_accept( + net::make_strand(ioc), + beast::bind_front_handler( + &http_listener::on_accept, + shared_from_this() + ) + ); +} + +void http_listener::on_accept(beast::error_code ec, tcp::socket socket) { + if (ec) { + std::cerr << "Couldn't accept incoming connection: " << ec.message() << std::endl; + return; + } + + std::make_shared( + std::move(socket), + doc_root + )->run(); + + do_accept(); +} \ No newline at end of file diff --git a/src/http_listener.hpp b/src/http_listener.hpp new file mode 100644 index 0000000..abc6d99 --- /dev/null +++ b/src/http_listener.hpp @@ -0,0 +1,29 @@ +#ifndef HTTP_LISTENER_HPP +#define HTTP_LISTENER_HPP + +#include +#include +#include +#include "common.hpp" +#include "http_session.hpp" + +class http_listener : public std::enable_shared_from_this { + net::io_context& ioc; + tcp::acceptor acceptor; + std::shared_ptr doc_root; + +public: + http_listener( + net::io_context& ioc, + tcp::endpoint endpoint, + const std::shared_ptr& doc_root + ); + + void run(); + +private: + void do_accept(); + void on_accept(beast::error_code ec, tcp::socket socket); +}; + +#endif \ No newline at end of file diff --git a/src/http_session.cpp b/src/http_session.cpp new file mode 100644 index 0000000..3b0c5f6 --- /dev/null +++ b/src/http_session.cpp @@ -0,0 +1,287 @@ +#include "http_session.hpp" + +http_session::http_session( + tcp::socket&& socket, + const std::shared_ptr& doc_root +) : stream(std::move(socket)), + doc_root(doc_root) {} + +void http_session::run() { + net::dispatch( + stream.get_executor(), + beast::bind_front_handler( + &http_session::do_read, + shared_from_this() + ) + ); +} + +void http_session::do_read() { + // clear the request + req = {}; + + stream.expires_after(std::chrono::seconds(3)); + + http::async_read(stream, buffer, req, + beast::bind_front_handler( + &http_session::on_read, + shared_from_this() + ) + ); +} + +void http_session::on_read( + beast::error_code ec, + std::size_t bytes_transferred +) { + boost::ignore_unused(bytes_transferred); + + // if the client closed the connection + if (ec == http::error::end_of_stream) { + return do_close(); + } + + if (ec) { + std::cerr << "Couldn't read an HTTP request from stream: " << ec.message() << std::endl; + return; + } + + send_response( + handle_request(*doc_root, std::move(req)) + ); +} + +void http_session::send_response(http::message_generator&& msg) { + bool keep_alive = msg.keep_alive(); + + beast::async_write( + stream, + std::move(msg), + beast::bind_front_handler( + &http_session::on_write, + shared_from_this(), + keep_alive + ) + ); +} + +std::string path_cat( + beast::string_view base, + beast::string_view path +) { + if (base.empty()) { + return std::string(path); + } + std::string result(base); + +#ifdef BOOST_MSVC + char constexpr path_separator = '\\'; + // if a path separator is present at the end of the base, remove it + if (result.back() == path_separator) { + result.resize(result.size() - 1); + } + result.append(path.data(), path.size()); + // replace any unix-like path separators with windows ones + for (auto& c : result) { + if (c == '/') { + c = path_separator; + } + } +#else + char constexpr path_separator = '/'; + if (result.back() == path_separator) { + result.resize(result.size() - 1); + } + result.append(path.data(), path.size()); +#endif + return result; +} + +beast::string_view mime_type( + beast::string_view path +) { + using beast::iequals; + // get the extension part of the path (after the last dot) + const auto ext = [&path]{ + const auto pos = path.rfind("."); + // if there is no dot in the path, there is no extension + if (pos == beast::string_view::npos) { + return beast::string_view{}; + } + return path.substr(pos); + }(); + + if (iequals(ext, ".htm")) return "text/html"; + if (iequals(ext, ".html")) return "text/html"; + if (iequals(ext, ".php")) return "text/html"; + if (iequals(ext, ".css")) return "text/css"; + if (iequals(ext, ".txt")) return "text/plain"; + if (iequals(ext, ".js")) return "application/javascript"; + if (iequals(ext, ".json")) return "application/json"; + if (iequals(ext, ".xml")) return "application/xml"; + if (iequals(ext, ".swf")) return "application/x-shockwave-flash"; + if (iequals(ext, ".flv")) return "video/x-flv"; + if (iequals(ext, ".png")) return "image/png"; + if (iequals(ext, ".jpe")) return "image/jpeg"; + if (iequals(ext, ".jpeg")) return "image/jpeg"; + if (iequals(ext, ".jpg")) return "image/jpeg"; + if (iequals(ext, ".gif")) return "image/gif"; + if (iequals(ext, ".bmp")) return "image/bmp"; + if (iequals(ext, ".ico")) return "image/vnd.microsoft.icon"; + if (iequals(ext, ".tiff")) return "image/tiff"; + if (iequals(ext, ".tif")) return "image/tiff"; + if (iequals(ext, ".svg")) return "image/svg+xml"; + if (iequals(ext, ".svgz")) return "image/svg+xml"; + return "application/text"; +} + +template +http::message_generator handle_request( + beast::string_view doc_root, + http::request>&& req +) { + const auto bad_request = + [&req] (beast::string_view why) { + http::response res{ + http::status::bad_request, + req.version() + }; + + res.set(http::field::server, "Gate Control Server"); + res.set(http::field::content_type, "text/html"); + res.keep_alive(req.keep_alive()); + res.body() = std::string(why); + res.prepare_payload(); + + return res; + }; + + const auto not_found = + [&req] (beast::string_view target) { + http::response res{ + http::status::not_found, + req.version() + }; + + res.set(http::field::server, "Gate Control Server"); + res.set(http::field::content_type, "text/html"); + res.keep_alive(req.keep_alive()); + res.body() = "The resource '" + std::string(target) + "' was not found."; + res.prepare_payload(); + + return res; + }; + + const auto server_error = + [&req] (beast::string_view what) { + http::response res{ + http::status::internal_server_error, + req.version() + }; + + res.set(http::field::server, "Gate Control Server"); + res.set(http::field::content_type, "text/html"); + res.keep_alive(req.keep_alive()); + res.body() = "An error occured: '" + std::string(what) + "'"; + res.prepare_payload(); + + return res; + }; + + // make sure we can handle the request + if ( + req.method() != http::verb::get && + req.method() != http::verb::head + ) { + return bad_request("Unknown HTTP method"); + } + + // the request path must not contain ".." (parent dir) and be absolute + if ( + req.target().empty() || + req.target()[0] != '/' || + req.target().find("..") != beast::string_view::npos + ) { + return bad_request("Illegal request target"); + } + + // build requested file path + std::string path = path_cat(doc_root, req.target()); + if (req.target().back() == '/') { + path.append("index.html"); + } + + // open the file + beast::error_code ec; + http::file_body::value_type body; + body.open(path.c_str(), beast::file_mode::scan, ec); + + // if the file doesn't exist, return the 404 error + if (ec == beast::errc::no_such_file_or_directory) { + return not_found(req.target()); + } + + // every other error is a server error + if (ec) { + return server_error(ec.message()); + } + + // save the size of the file for later + const auto size = body.size(); + + // if the request method is HEAD + if (req.method() == http::verb::head) { + http::response res{ + http::status::ok, + req.version() + }; + + res.set(http::field::server, "Gate Control Server"); + res.set(http::field::content_type, mime_type(path)); + res.content_length(size); + res.keep_alive(req.keep_alive()); + + return res; + } + + // if the request method is GET + http::response res{ + std::piecewise_construct, + std::make_tuple(std::move(body)), + std::make_tuple(http::status::ok, req.version()) + }; + + res.set(http::field::server, "Gate Control Server"); + res.set(http::field::content_type, mime_type(path)); + res.content_length(size); + res.keep_alive(req.keep_alive()); + return res; +} + +void http_session::on_write( + bool keep_alive, + beast::error_code ec, + std::size_t bytes_transferred +) { + boost::ignore_unused(bytes_transferred); + + if (ec) { + std::cerr << "Couldn't write to TCP stream: " << ec.message() << std::endl; + return; + } + + if (!keep_alive) { + return do_close(); + } + + do_read(); +} + +void http_session::do_close() { + beast::error_code ec; + stream.socket().shutdown(tcp::socket::shutdown_send, ec); + + if (ec) { + std::cerr << "Couldn't shutdown TCP connection: " << ec.message() << std::endl; + } +} \ No newline at end of file diff --git a/src/http_session.hpp b/src/http_session.hpp new file mode 100644 index 0000000..5162f94 --- /dev/null +++ b/src/http_session.hpp @@ -0,0 +1,58 @@ +#ifndef HTTP_SESSION_HPP +#define HTTP_SESSION_HPP + +#include "common.hpp" +#include +#include +#include + +beast::string_view mime_type( + beast::string_view path +); + +std::string path_cat( + beast::string_view base, + beast::string_view path +); + +// handle given request by returning an appropriate response +template +http::message_generator handle_request( + beast::string_view doc_root, + http::request>&& req +); + +class http_session : public std::enable_shared_from_this { + beast::tcp_stream stream; + beast::flat_buffer buffer; + std::shared_ptr doc_root; + http::request req; + +public: + http_session( + tcp::socket&& socket, + const std::shared_ptr& doc_root + ); + + void run(); + + void do_read(); + void on_read( + beast::error_code ec, + std::size_t bytes_transferred + ); + + void send_response(http::message_generator&& msg); + + void do_close(); + + void on_write( + bool keep_alive, + beast::error_code ec, + std::size_t bytes_transferred + ); +}; + + + +#endif \ No newline at end of file diff --git a/src/main.cpp b/src/main.cpp index 1633257..6aba192 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,6 +1,37 @@ -#include #include +#include +#include +#include +#include +#include "common.hpp" +#include "http_listener.hpp" + +const auto ADDRESS = net::ip::make_address_v4("0.0.0.0"); +const auto PORT = static_cast(80); +const auto DOC_ROOT = std::make_shared("./client"); +const auto THREAD_COUNT = 8; int main() { + net::io_context ioc{THREAD_COUNT}; + + std::make_shared( + ioc, + tcp::endpoint{ADDRESS, PORT}, + DOC_ROOT + )->run(); + + std::cout << "Server started at " << ADDRESS << ":" << PORT << "." << std::endl; + + std::vector v; + v.reserve(THREAD_COUNT - 1); + for (auto i = 0; i < THREAD_COUNT - 1; ++i) { + v.emplace_back( + [&ioc] { + ioc.run(); + } + ); + } + ioc.run(); + return 0; } \ No newline at end of file