From 9fbf273caa4bd9be66bed03e20c5f605ecdaca6d Mon Sep 17 00:00:00 2001 From: Raphael Nestler Date: Fri, 24 Jan 2025 21:29:47 +0100 Subject: [PATCH 01/37] Fix typo in stream_plugin.md (#1333) --- doc/json_rpc_api/stream_plugin.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/json_rpc_api/stream_plugin.md b/doc/json_rpc_api/stream_plugin.md index 2a23bc51..36fa6a63 100644 --- a/doc/json_rpc_api/stream_plugin.md +++ b/doc/json_rpc_api/stream_plugin.md @@ -92,7 +92,7 @@ Snapserver will send the `SetProperty` command to the plugin, if `canControl` is * `track`: the current track will start again from the begining once it has finished playing * `playlist`: the playback loops through a list of tracks * `shuffle`: [bool] play playlist in random order -* `volume`: [int] voume in percent, valid range [0..100] +* `volume`: [int] volume in percent, valid range [0..100] * `mute`: [bool] the current mute state * `rate`: [float] the current playback rate, valid range (0..) From 6c02252d84f44b71471e24dfbc0c025db652078b Mon Sep 17 00:00:00 2001 From: badaix Date: Thu, 23 Jan 2025 22:13:05 +0100 Subject: [PATCH 02/37] Add client support for websockets --- client/client_connection.cpp | 352 +++++++++++++----- client/client_connection.hpp | 88 ++++- client/client_settings.hpp | 11 +- client/controller.cpp | 7 +- client/controller.hpp | 5 +- client/snapclient.cpp | 10 + common/CMakeLists.txt | 2 +- .../streamreader => common}/stream_uri.cpp | 11 +- .../streamreader => common}/stream_uri.hpp | 3 + server/CMakeLists.txt | 1 - server/streamreader/pcm_stream.hpp | 2 +- test/CMakeLists.txt | 4 +- test/test_main.cpp | 15 +- 13 files changed, 393 insertions(+), 118 deletions(-) rename {server/streamreader => common}/stream_uri.cpp (94%) rename {server/streamreader => common}/stream_uri.hpp (96%) diff --git a/client/client_connection.cpp b/client/client_connection.cpp index 603c3d43..6a716258 100644 --- a/client/client_connection.cpp +++ b/client/client_connection.cpp @@ -24,16 +24,22 @@ #include "common/str_compat.hpp" // 3rd party headers +#include #include #include #include // standard headers +#include +#include #include #include +#include +#include using namespace std; +namespace http = beast::http; // from static constexpr auto LOG_TAG = "Connection"; @@ -93,35 +99,92 @@ bool PendingRequest::operator<(const PendingRequest& other) const ClientConnection::ClientConnection(boost::asio::io_context& io_context, ClientSettings::Server server) - : strand_(boost::asio::make_strand(io_context.get_executor())), resolver_(strand_), socket_(strand_), reqId_(1), server_(std::move(server)) + : strand_(boost::asio::make_strand(io_context.get_executor())), resolver_(strand_), reqId_(1), server_(std::move(server)), + base_msg_size_(base_message_.getSize()) { - base_msg_size_ = base_message_.getSize(); - buffer_.resize(base_msg_size_); } -ClientConnection::~ClientConnection() +void ClientConnection::sendNext() +{ + auto& message = messages_.front(); + boost::asio::streambuf streambuf; + std::ostream stream(&streambuf); + tv t; + message.msg->sent = t; + message.msg->serialize(stream); + ResultHandler handler = message.handler; + + write(streambuf, [this, handler](boost::system::error_code ec, std::size_t length) + { + if (ec) + LOG(ERROR, LOG_TAG) << "Failed to send message, error: " << ec.message() << "\n"; + else + LOG(TRACE, LOG_TAG) << "Wrote " << length << " bytes to socket\n"; + + messages_.pop_front(); + if (handler) + handler(ec); + + if (!messages_.empty()) + sendNext(); + }); +} + + +void ClientConnection::send(const msg::message_ptr& message, const ResultHandler& handler) +{ + boost::asio::post(strand_, [this, message, handler]() + { + messages_.emplace_back(message, handler); + if (messages_.size() > 1) + { + LOG(DEBUG, LOG_TAG) << "outstanding async_write\n"; + return; + } + sendNext(); + }); +} + + +void ClientConnection::sendRequest(const msg::message_ptr& message, const chronos::usec& timeout, const MessageHandler& handler) +{ + boost::asio::post(strand_, [this, message, timeout, handler]() + { + pendingRequests_.erase( + std::remove_if(pendingRequests_.begin(), pendingRequests_.end(), [](const std::weak_ptr& request) { return request.expired(); }), + pendingRequests_.end()); + unique_ptr response(nullptr); + static constexpr uint16_t max_req_id = 10000; + if (++reqId_ >= max_req_id) + reqId_ = 1; + message->id = reqId_; + auto request = make_shared(strand_, reqId_, handler); + pendingRequests_.push_back(request); + request->startTimer(timeout); + send(message, [handler](const boost::system::error_code& ec) + { + if (ec) + handler(ec, nullptr); + }); + }); +} + + +///////////////////////////////////// TCP ///////////////////////////////////// + +ClientConnectionTcp::ClientConnectionTcp(boost::asio::io_context& io_context, ClientSettings::Server server) + : ClientConnection(io_context, std::move(server)), socket_(strand_) +{ + buffer_.resize(base_msg_size_); +} + +ClientConnectionTcp::~ClientConnectionTcp() { disconnect(); } - -std::string ClientConnection::getMacAddress() -{ - std::string mac = -#ifndef WINDOWS - ::getMacAddress(socket_.native_handle()); -#else - ::getMacAddress(socket_.local_endpoint().address().to_string()); -#endif - if (mac.empty()) - mac = "00:00:00:00:00:00"; - LOG(INFO, LOG_TAG) << "My MAC: \"" << mac << "\", socket: " << socket_.native_handle() << "\n"; - return mac; -} - - -void ClientConnection::connect(const ResultHandler& handler) +void ClientConnectionTcp::connect(const ResultHandler& handler) { boost::system::error_code ec; LOG(INFO, LOG_TAG) << "Resolving host IP for: " << server_.host << "\n"; @@ -180,8 +243,7 @@ void ClientConnection::connect(const ResultHandler& handler) #endif } - -void ClientConnection::disconnect() +void ClientConnectionTcp::disconnect() { LOG(DEBUG, LOG_TAG) << "Disconnecting\n"; if (!socket_.is_open()) @@ -201,73 +263,22 @@ void ClientConnection::disconnect() } -void ClientConnection::sendNext() +std::string ClientConnectionTcp::getMacAddress() { - auto& message = messages_.front(); - static boost::asio::streambuf streambuf; - std::ostream stream(&streambuf); - tv t; - message.msg->sent = t; - message.msg->serialize(stream); - auto handler = message.handler; - - boost::asio::async_write(socket_, streambuf, [this, handler](boost::system::error_code ec, std::size_t length) - { - if (ec) - LOG(ERROR, LOG_TAG) << "Failed to send message, error: " << ec.message() << "\n"; - else - LOG(TRACE, LOG_TAG) << "Wrote " << length << " bytes to socket\n"; - - messages_.pop_front(); - if (handler) - handler(ec); - - if (!messages_.empty()) - sendNext(); - }); + std::string mac = +#ifndef WINDOWS + ::getMacAddress(socket_.native_handle()); +#else + ::getMacAddress(socket_.local_endpoint().address().to_string()); +#endif + if (mac.empty()) + mac = "00:00:00:00:00:00"; + LOG(INFO, LOG_TAG) << "My MAC: \"" << mac << "\", socket: " << socket_.native_handle() << "\n"; + return mac; } -void ClientConnection::send(const msg::message_ptr& message, const ResultHandler& handler) -{ - boost::asio::post(strand_, [this, message, handler]() - { - messages_.emplace_back(message, handler); - if (messages_.size() > 1) - { - LOG(DEBUG, LOG_TAG) << "outstanding async_write\n"; - return; - } - sendNext(); - }); -} - - -void ClientConnection::sendRequest(const msg::message_ptr& message, const chronos::usec& timeout, const MessageHandler& handler) -{ - boost::asio::post(strand_, [this, message, timeout, handler]() - { - pendingRequests_.erase( - std::remove_if(pendingRequests_.begin(), pendingRequests_.end(), [](const std::weak_ptr& request) { return request.expired(); }), - pendingRequests_.end()); - unique_ptr response(nullptr); - static constexpr uint16_t max_req_id = 10000; - if (++reqId_ >= max_req_id) - reqId_ = 1; - message->id = reqId_; - auto request = make_shared(strand_, reqId_, handler); - pendingRequests_.push_back(request); - request->startTimer(timeout); - send(message, [handler](const boost::system::error_code& ec) - { - if (ec) - handler(ec, nullptr); - }); - }); -} - - -void ClientConnection::getNextMessage(const MessageHandler& handler) +void ClientConnectionTcp::getNextMessage(const MessageHandler& handler) { boost::asio::async_read(socket_, boost::asio::buffer(buffer_, base_msg_size_), [this, handler](boost::system::error_code ec, std::size_t length) mutable { @@ -336,3 +347,172 @@ void ClientConnection::getNextMessage(const MessageHandler& ha }); }); } + + +void ClientConnectionTcp::write(boost::asio::streambuf& buffer, WriteHandler&& write_handler) +{ + boost::asio::async_write(socket_, buffer, write_handler); +} + + +///////////////////////////////// Websockets ////////////////////////////////// + + +ClientConnectionWs::ClientConnectionWs(boost::asio::io_context& io_context, ClientSettings::Server server) + : ClientConnection(io_context, std::move(server)), tcp_ws_(strand_) +{ +} + + +ClientConnectionWs::~ClientConnectionWs() +{ + disconnect(); +} + + +void ClientConnectionWs::connect(const ResultHandler& handler) +{ + boost::system::error_code ec; + LOG(INFO, LOG_TAG) << "Resolving host IP for: " << server_.host << "\n"; + auto iterator = resolver_.resolve(server_.host, cpt::to_string(server_.port), boost::asio::ip::resolver_query_base::numeric_service, ec); + if (ec) + { + LOG(ERROR, LOG_TAG) << "Failed to resolve host '" << server_.host << "', error: " << ec.message() << "\n"; + handler(ec); + return; + } + + for (const auto& iter : iterator) + LOG(DEBUG, LOG_TAG) << "Resolved IP: " << iter.endpoint().address().to_string() << "\n"; + + for (const auto& iter : iterator) + { + LOG(INFO, LOG_TAG) << "Connecting to " << iter.endpoint() << "\n"; + if (tcp_ws_) + { + tcp_ws_->binary(true); + tcp_ws_->next_layer().connect(iter, ec); + + // Set suggested timeout settings for the websocket + tcp_ws_->set_option(websocket::stream_base::timeout::suggested(beast::role_type::client)); + + // Set a decorator to change the User-Agent of the handshake + tcp_ws_->set_option(websocket::stream_base::decorator([](websocket::request_type& req) + { req.set(http::field::user_agent, std::string(BOOST_BEAST_VERSION_STRING) + " websocket-client-async"); })); + + // Perform the websocket handshake + tcp_ws_->handshake("127.0.0.1", "/stream", ec); + handler(ec); + return; + } + + if (!ec || (ec == boost::system::errc::interrupted)) + { + // We were successful or interrupted, e.g. by sig int + break; + } + } + + if (ec) + LOG(ERROR, LOG_TAG) << "Failed to connect to host '" << server_.host << "', error: " << ec.message() << "\n"; + else + LOG(NOTICE, LOG_TAG) << "Connected to " << tcp_ws_->next_layer().remote_endpoint().address().to_string() << "\n"; + + handler(ec); +} + + +void ClientConnectionWs::disconnect() +{ + LOG(DEBUG, LOG_TAG) << "Disconnecting\n"; + if (!tcp_ws_->is_open()) + { + LOG(DEBUG, LOG_TAG) << "Not connected\n"; + return; + } + boost::system::error_code ec; + tcp_ws_->close(websocket::close_code::normal, ec); + if (ec) + LOG(ERROR, LOG_TAG) << "Error in socket close: " << ec.message() << "\n"; + boost::asio::post(strand_, [this]() { pendingRequests_.clear(); }); + LOG(DEBUG, LOG_TAG) << "Disconnected\n"; +} + + +std::string ClientConnectionWs::getMacAddress() +{ + std::string mac = +#ifndef WINDOWS + ::getMacAddress(tcp_ws_->next_layer().native_handle()); +#else + ::getMacAddress(tcp_ws_->next_layer().local_endpoint().address().to_string()); +#endif + if (mac.empty()) + mac = "00:00:00:00:00:00"; + LOG(INFO, LOG_TAG) << "My MAC: \"" << mac << "\", socket: " << tcp_ws_->next_layer().native_handle() << "\n"; + return mac; +} + + +void ClientConnectionWs::getNextMessage(const MessageHandler& handler) +{ + tcp_ws_->async_read(buffer_, [this, handler](beast::error_code ec, std::size_t bytes_transferred) mutable + { + tv now; + LOG(DEBUG, LOG_TAG) << "on_read_ws, ec: " << ec << ", bytes_transferred: " << bytes_transferred << "\n"; + + // This indicates that the session was closed + if (ec == websocket::error::closed) + { + if (handler) + handler(ec, nullptr); + return; + } + + if (ec) + { + LOG(ERROR, LOG_TAG) << "ControlSessionWebsocket::on_read_ws error: " << ec.message() << "\n"; + if (handler) + handler(ec, nullptr); + return; + } + + buffer_.consume(bytes_transferred); + + auto* data = static_cast(buffer_.data().data()); + base_message_.deserialize(data); + + base_message_.received = now; + + auto response = msg::factory::createMessage(base_message_, data + base_msg_size_); + if (!response) + LOG(WARNING, LOG_TAG) << "Failed to deserialize message of type: " << base_message_.type << "\n"; + else + LOG(DEBUG, LOG_TAG) << "getNextMessage: " << response->type << ", size: " << response->size << ", id: " << response->id + << ", refers: " << response->refersTo << "\n"; + + for (auto iter = pendingRequests_.begin(); iter != pendingRequests_.end(); ++iter) + { + auto request = *iter; + if (auto req = request.lock()) + { + if (req->id() == base_message_.refersTo) + { + req->setValue(std::move(response)); + pendingRequests_.erase(iter); + getNextMessage(handler); + return; + } + } + } + + if (handler) + handler(ec, std::move(response)); + }); +} + + +void ClientConnectionWs::write(boost::asio::streambuf& buffer, WriteHandler&& write_handler) +{ + tcp_ws_->async_write(boost::asio::buffer(buffer.data()), write_handler); +} diff --git a/client/client_connection.hpp b/client/client_connection.hpp index ad9f36ee..94dfb82e 100644 --- a/client/client_connection.hpp +++ b/client/client_connection.hpp @@ -29,6 +29,10 @@ #include #include #include +#include +#include +#include +#include // standard headers #include @@ -36,7 +40,13 @@ #include -using boost::asio::ip::tcp; +// using boost::asio::ip::tcp; +namespace beast = boost::beast; // from +namespace websocket = beast::websocket; // from +using tcp_socket = boost::asio::ip::tcp::socket; +using ssl_socket = boost::asio::ssl::stream; +using tcp_websocket = websocket::stream; +using ssl_websocket = websocket::stream; class ClientConnection; @@ -87,17 +97,19 @@ class ClientConnection public: /// Result callback with boost::error_code using ResultHandler = std::function; + /// Result callback of a write operation + using WriteHandler = std::function; /// c'tor ClientConnection(boost::asio::io_context& io_context, ClientSettings::Server server); /// d'tor - virtual ~ClientConnection(); + virtual ~ClientConnection() = default; /// async connect /// @param handler async result handler - void connect(const ResultHandler& handler); + virtual void connect(const ResultHandler& handler) = 0; /// disconnect the socket - void disconnect(); + virtual void disconnect() = 0; /// async send a message /// @param message the message @@ -126,35 +138,35 @@ public: } /// @return MAC address of the client - std::string getMacAddress(); + virtual std::string getMacAddress() = 0; /// async get the next message /// @param handler the next received message or error - void getNextMessage(const MessageHandler& handler); + virtual void getNextMessage(const MessageHandler& handler) = 0; protected: + virtual void write(boost::asio::streambuf& buffer, WriteHandler&& write_handler) = 0; + /// Send next pending message from messages_ void sendNext(); /// Base message holding the received message msg::BaseMessage base_message_; - /// Receive buffer - std::vector buffer_; - /// Size of a base message (= message header) - size_t base_msg_size_; /// Strand to serialize send/receive boost::asio::strand strand_; + /// TCP resolver - tcp::resolver resolver_; - /// TCP socket - tcp::socket socket_; + boost::asio::ip::tcp::resolver resolver_; + /// List of pending requests, waiting for a response (Message::refersTo) std::vector> pendingRequests_; /// unique request id to match a response uint16_t reqId_; /// Server settings (host and port) ClientSettings::Server server_; + /// Size of a base message (= message header) + const size_t base_msg_size_; /// A pending request struct PendingMessage @@ -172,3 +184,53 @@ protected: /// Pending messages to be sent std::deque messages_; }; + + +/// Plain TCP connection +class ClientConnectionTcp : public ClientConnection +{ +public: + /// c'tor + ClientConnectionTcp(boost::asio::io_context& io_context, ClientSettings::Server server); + /// d'tor + virtual ~ClientConnectionTcp(); + + void connect(const ResultHandler& handler) override; + void disconnect() override; + std::string getMacAddress() override; + void getNextMessage(const MessageHandler& handler) override; + +private: + void write(boost::asio::streambuf& buffer, WriteHandler&& write_handler) override; + + /// TCP socket + tcp_socket socket_; + /// Receive buffer + std::vector buffer_; +}; + + +/// Websocket connection +class ClientConnectionWs : public ClientConnection +{ +public: + /// c'tor + ClientConnectionWs(boost::asio::io_context& io_context, ClientSettings::Server server); + /// d'tor + virtual ~ClientConnectionWs(); + + void connect(const ResultHandler& handler) override; + void disconnect() override; + std::string getMacAddress() override; + void getNextMessage(const MessageHandler& handler) override; + +private: + void write(boost::asio::streambuf& buffer, WriteHandler&& write_handler) override; + + /// SSL web socket + // std::optional ssl_ws_; + /// TCP web socket + std::optional tcp_ws_; + /// Receive buffer + boost::beast::flat_buffer buffer_; +}; diff --git a/client/client_settings.hpp b/client/client_settings.hpp index 877216c2..6ebaa20a 100644 --- a/client/client_settings.hpp +++ b/client/client_settings.hpp @@ -47,19 +47,20 @@ struct ClientSettings }; Mode mode{Mode::software}; - std::string parameter{""}; + std::string parameter; }; struct Server { - std::string host{""}; + std::string host; + std::string protocol; size_t port{1704}; }; struct Player { - std::string player_name{""}; - std::string parameter{""}; + std::string player_name; + std::string parameter; int latency{0}; player::PcmDevice pcm_device; SampleFormat sample_format; @@ -69,7 +70,7 @@ struct ClientSettings struct Logging { - std::string sink{""}; + std::string sink; std::string filter{"*:info"}; }; diff --git a/client/controller.cpp b/client/controller.cpp index 7561aaa6..add7eb8c 100644 --- a/client/controller.cpp +++ b/client/controller.cpp @@ -354,14 +354,17 @@ void Controller::start() settings_.server.host = host; settings_.server.port = port; LOG(INFO, LOG_TAG) << "Found server " << settings_.server.host << ":" << settings_.server.port << "\n"; - clientConnection_ = make_unique(io_context_, settings_.server); + clientConnection_ = make_unique(io_context_, settings_.server); worker(); } }); } else { - clientConnection_ = make_unique(io_context_, settings_.server); + if (settings_.server.protocol == "ws") + clientConnection_ = make_unique(io_context_, settings_.server); + else + clientConnection_ = make_unique(io_context_, settings_.server); worker(); } } diff --git a/client/controller.hpp b/client/controller.hpp index 27299bf1..ba605c0f 100644 --- a/client/controller.hpp +++ b/client/controller.hpp @@ -1,6 +1,6 @@ /*** This file is part of snapcast - Copyright (C) 2014-2024 Johannes Pohl + Copyright (C) 2014-2025 Johannes Pohl This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by @@ -43,9 +43,12 @@ using namespace std::chrono_literals; class Controller { public: + /// c'tor Controller(boost::asio::io_context& io_context, const ClientSettings& settings); //, std::unique_ptr meta); + /// Start thw work void start(); // void stop(); + /// @return list of supported audio backends static std::vector getSupportedPlayerNames(); private: diff --git a/client/snapclient.cpp b/client/snapclient.cpp index 4cfbbfaf..e5594b25 100644 --- a/client/snapclient.cpp +++ b/client/snapclient.cpp @@ -18,6 +18,7 @@ // local headers #include "common/popl.hpp" +#include "common/utils/string_utils.hpp" #include "controller.hpp" #ifdef HAS_ALSA @@ -37,6 +38,7 @@ #include "common/aixlog.hpp" #include "common/snap_exception.hpp" #include "common/str_compat.hpp" +#include "common/stream_uri.hpp" #include "common/version.hpp" // 3rd party headers @@ -202,6 +204,14 @@ int main(int argc, char** argv) exit(EXIT_FAILURE); } + if (!op.non_option_args().empty()) + { + streamreader::StreamUri uri(op.non_option_args().front()); + settings.server.host = uri.host; + settings.server.port = uri.port.value_or(settings.server.port); + settings.server.protocol = uri.scheme; + } + if (versionSwitch->is_set()) { cout << "snapclient v" << version::code << (!version::rev().empty() ? (" (rev " + version::rev(8) + ")") : ("")) << "\n" diff --git a/common/CMakeLists.txt b/common/CMakeLists.txt index 4891920d..be24a5ec 100644 --- a/common/CMakeLists.txt +++ b/common/CMakeLists.txt @@ -1,4 +1,4 @@ -set(SOURCES resampler.cpp sample_format.cpp jwt.cpp base64.cpp +set(SOURCES resampler.cpp sample_format.cpp jwt.cpp base64.cpp stream_uri.cpp utils/string_utils.cpp) if(NOT WIN32 AND NOT ANDROID) diff --git a/server/streamreader/stream_uri.cpp b/common/stream_uri.cpp similarity index 94% rename from server/streamreader/stream_uri.cpp rename to common/stream_uri.cpp index a9f645a9..d3409d0a 100644 --- a/server/streamreader/stream_uri.cpp +++ b/common/stream_uri.cpp @@ -18,6 +18,7 @@ #ifndef NOMINMAX #define NOMINMAX +#include #endif // NOMINMAX // prototype/interface header file @@ -87,6 +88,12 @@ void StreamUri::parse(const std::string& stream_uri) // pos: ^ or ^ or ^ host = strutils::uriDecode(strutils::trim_copy(tmp.substr(0, pos))); + std::string str_port; + host = utils::string::split_left(host, ':', str_port); + port = std::atoi(str_port.c_str()); + if (port == 0) + port = std::nullopt; + tmp = tmp.substr(pos); path = tmp; pos = std::min(path.find('?'), path.find('#')); @@ -166,9 +173,11 @@ std::string StreamUri::getQuery(const std::string& key, const std::string& def) return def; } + bool StreamUri::operator==(const StreamUri& other) const { - return (other.scheme == scheme) && (other.host == host) && (other.path == path) && (other.query == query) && (other.fragment == fragment); + return (other.scheme == scheme) && (other.host == host) && (other.port == port) && (other.path == path) && (other.query == query) && + (other.fragment == fragment); } } // namespace streamreader diff --git a/server/streamreader/stream_uri.hpp b/common/stream_uri.hpp similarity index 96% rename from server/streamreader/stream_uri.hpp rename to common/stream_uri.hpp index d95419cb..fce5bbc4 100644 --- a/server/streamreader/stream_uri.hpp +++ b/common/stream_uri.hpp @@ -24,6 +24,7 @@ // standard headers #include +#include #include @@ -54,6 +55,8 @@ struct StreamUri /// the host component std::string host; + /// the port + std::optional port; /// the path component std::string path; /// the query component: "key = value" pairs diff --git a/server/CMakeLists.txt b/server/CMakeLists.txt index 1c15f7ee..265825a0 100644 --- a/server/CMakeLists.txt +++ b/server/CMakeLists.txt @@ -17,7 +17,6 @@ set(SERVER_SOURCES encoder/null_encoder.cpp streamreader/control_error.cpp streamreader/stream_control.cpp - streamreader/stream_uri.cpp streamreader/stream_manager.cpp streamreader/pcm_stream.cpp streamreader/tcp_stream.cpp diff --git a/server/streamreader/pcm_stream.hpp b/server/streamreader/pcm_stream.hpp index b55755b5..5148c5ac 100644 --- a/server/streamreader/pcm_stream.hpp +++ b/server/streamreader/pcm_stream.hpp @@ -24,12 +24,12 @@ #include "common/json.hpp" #include "common/message/codec_header.hpp" #include "common/sample_format.hpp" +#include "common/stream_uri.hpp" #include "encoder/encoder.hpp" #include "jsonrpcpp.hpp" #include "properties.hpp" #include "server_settings.hpp" #include "stream_control.hpp" -#include "stream_uri.hpp" // 3rd party headers #include diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index d903c2f3..7f21c00a 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -17,13 +17,13 @@ endif() set(TEST_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/test_main.cpp ${CMAKE_SOURCE_DIR}/common/jwt.cpp + ${CMAKE_SOURCE_DIR}/common/stream_uri.cpp ${CMAKE_SOURCE_DIR}/common/base64.cpp ${CMAKE_SOURCE_DIR}/common/utils/string_utils.cpp ${CMAKE_SOURCE_DIR}/server/authinfo.cpp ${CMAKE_SOURCE_DIR}/server/streamreader/control_error.cpp ${CMAKE_SOURCE_DIR}/server/streamreader/properties.cpp - ${CMAKE_SOURCE_DIR}/server/streamreader/metadata.cpp - ${CMAKE_SOURCE_DIR}/server/streamreader/stream_uri.cpp) + ${CMAKE_SOURCE_DIR}/server/streamreader/metadata.cpp) include_directories(SYSTEM ${Boost_INCLUDE_DIR}) diff --git a/test/test_main.cpp b/test/test_main.cpp index a35bae96..9023403c 100644 --- a/test/test_main.cpp +++ b/test/test_main.cpp @@ -23,12 +23,12 @@ #include "common/base64.h" #include "common/error_code.hpp" #include "common/jwt.hpp" +#include "common/stream_uri.hpp" #include "common/utils/string_utils.hpp" #include "server/authinfo.hpp" #include "server/server_settings.hpp" #include "server/streamreader/control_error.hpp" #include "server/streamreader/properties.hpp" -#include "server/streamreader/stream_uri.hpp" // 3rd party headers #include @@ -232,9 +232,11 @@ TEST_CASE("Uri") // uri = StreamUri("scheme:[//host[:port]][/]path[?query=none][#fragment]"); // Test with all fields - uri = StreamUri("scheme://host:port/path?query=none&key=value#fragment"); + uri = StreamUri("scheme://host:42/path?query=none&key=value#fragment"); REQUIRE(uri.scheme == "scheme"); - REQUIRE(uri.host == "host:port"); + REQUIRE(uri.host == "host"); + REQUIRE(uri.port.has_value()); + REQUIRE(uri.port.value() == 42); REQUIRE(uri.path == "/path"); REQUIRE(uri.query["query"] == "none"); REQUIRE(uri.query["key"] == "value"); @@ -243,9 +245,11 @@ TEST_CASE("Uri") // Test with all fields, url encoded // "%21%23%24%25%26%27%28%29%2A%2B%2C%2F%3A%3B%3D%3F%40%5B%5D" // "!#$%&'()*+,/:;=?@[]" - uri = StreamUri("scheme%26://%26host%3f:port/pa%2Bth?%21%23%24%25%26%27%28%29=%2A%2B%2C%2F%3A%3B%3D%3F%40%5B%5D&key%2525=value#fragment%3f%21%3F"); + uri = StreamUri("scheme%26://%26host%3f:23/pa%2Bth?%21%23%24%25%26%27%28%29=%2A%2B%2C%2F%3A%3B%3D%3F%40%5B%5D&key%2525=value#fragment%3f%21%3F"); REQUIRE(uri.scheme == "scheme&"); - REQUIRE(uri.host == "&host?:port"); + REQUIRE(uri.host == "&host?"); + REQUIRE(uri.port.has_value()); + REQUIRE(uri.port.value() == 23); REQUIRE(uri.path == "/pa+th"); REQUIRE(uri.query["!#$%&'()"] == "*+,/:;=?@[]"); REQUIRE(uri.query["key%25"] == "value"); @@ -283,6 +287,7 @@ TEST_CASE("Uri") uri = StreamUri("spotify:///librespot?name=Spotify&username=EMAIL&password=string%26with%26ampersands&devicename=Snapcast&bitrate=320&killall=false"); REQUIRE(uri.scheme == "spotify"); REQUIRE(uri.host.empty()); + REQUIRE(!uri.port.has_value()); REQUIRE(uri.path == "/librespot"); REQUIRE(uri.query["name"] == "Spotify"); REQUIRE(uri.query["username"] == "EMAIL"); From 355c75458a254fbd00a32a8ea36fc64ede7f0419 Mon Sep 17 00:00:00 2001 From: badaix Date: Thu, 23 Jan 2025 22:30:11 +0100 Subject: [PATCH 03/37] Add code comments --- client/client_settings.hpp | 43 ++++++++++++++++++++++++++++-------- client/player/pcm_device.hpp | 15 ++++++++----- 2 files changed, 44 insertions(+), 14 deletions(-) diff --git a/client/client_settings.hpp b/client/client_settings.hpp index 6ebaa20a..5ea4adc0 100644 --- a/client/client_settings.hpp +++ b/client/client_settings.hpp @@ -1,6 +1,6 @@ /*** This file is part of snapcast - Copyright (C) 2014-2024 Johannes Pohl + Copyright (C) 2014-2025 Johannes Pohl This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by @@ -26,58 +26,83 @@ #include - +/// Snapclient settings struct ClientSettings { + /// Sharing mode for audio device enum class SharingMode { - unspecified, - exclusive, - shared + unspecified, ///< unspecified + exclusive, ///< exclusice access + shared ///< shared access }; + /// Mixer settings struct Mixer { + /// Mixer mode enum class Mode { - hardware, - software, - script, - none + hardware, ///< hardware mixer + software, ///< software mixer + script, ///< run a mixer script + none ///< no mixer }; + /// the configured mixer mode Mode mode{Mode::software}; + /// mixer parameter std::string parameter; }; + /// Server settings struct Server { + /// server host or IP address std::string host; + /// protocol: "tcp", "ws" or "wss" std::string protocol; + /// server port size_t port{1704}; }; + /// The audio player (DAC) struct Player { + /// name of the player std::string player_name; + /// player parameters std::string parameter; + /// additional latency of the DAC [ms] int latency{0}; + /// the DAC player::PcmDevice pcm_device; + /// Sampleformat to be uses, i.e. 48000:16:2 SampleFormat sample_format; + /// The sharing mode SharingMode sharing_mode{SharingMode::unspecified}; + /// Mixer settings Mixer mixer; }; + /// Log settings struct Logging { + /// The log sink (null,system,stdout,stderr,file:) std::string sink; + /// Log filter std::string filter{"*:info"}; }; + /// The snapclient process instance size_t instance{1}; + /// The host id, presented to the server std::string host_id; + /// Server settings Server server; + /// Player settings Player player; + /// Logging settings Logging logging; }; diff --git a/client/player/pcm_device.hpp b/client/player/pcm_device.hpp index 0cb6af6d..17a03784 100644 --- a/client/player/pcm_device.hpp +++ b/client/player/pcm_device.hpp @@ -1,6 +1,6 @@ /*** This file is part of snapcast - Copyright (C) 2014-2020 Johannes Pohl + Copyright (C) 2014-2025 Johannes Pohl This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by @@ -16,27 +16,32 @@ along with this program. If not, see . ***/ -#ifndef PCM_DEVICE_HPP -#define PCM_DEVICE_HPP +#pragma once +// standard headers #include namespace player { +/// Name of the default audio device static constexpr char DEFAULT_DEVICE[] = "default"; +/// DAC identifier struct PcmDevice { + /// c'tor PcmDevice() : idx(-1), name(DEFAULT_DEVICE){}; + /// c'tor PcmDevice(int idx, const std::string& name, const std::string& description = "") : idx(idx), name(name), description(description){}; + /// index of the DAC (as in "aplay -L") int idx; + /// device name std::string name; + /// device description std::string description; }; } // namespace player - -#endif From bd424a399295f6a26bb9dffbbc84ccda466479ad Mon Sep 17 00:00:00 2001 From: badaix Date: Thu, 23 Jan 2025 22:30:40 +0100 Subject: [PATCH 04/37] Install boost beast --- .github/workflows/ci.yml | 4 ++-- .github/workflows/package.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b879560f..8e61b41d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,7 +75,7 @@ jobs: mkdir -p build/doxygen doxygen 2>&1 | tee build/doxygen.log WARNINGS=$(cat build/doxygen.log | sort | uniq | grep -e ": warning: " | wc -l) - MAX_ALLOWED=768 + MAX_ALLOWED=724 echo "Doxygen finished with $WARNINGS warnings, max allowed: $MAX_ALLOWED" if [ "$WARNINGS" -gt "$MAX_ALLOWED" ]; then exit $WARNINGS; else exit 0; fi; @@ -408,7 +408,7 @@ jobs: cd c:\vcpkg git pull vcpkg.exe update - vcpkg.exe --triplet x64-windows install libflac libvorbis soxr opus boost-asio catch2 + vcpkg.exe --triplet x64-windows install libflac libvorbis soxr opus boost-asio boost-beast catch2 - name: configure run: | echo vcpkg installation root: ${env:VCPKG_INSTALLATION_ROOT} diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index cf87fab9..2ac93e13 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -169,7 +169,7 @@ jobs: key: ${{ runner.os }}-dependencies - name: Get dependenciesenv if: steps.cache-dependencies.outputs.cache-hit != 'true' - run: vcpkg.exe install libflac libvorbis soxr opus boost-asio --triplet x64-windows + run: vcpkg.exe install libflac libvorbis soxr opus boost-asio boost-beast --triplet x64-windows - name: configure run: | echo vcpkg installation root: $env:VCPKG_INSTALLATION_ROOT From 0a8b737f9f3bb567cf53ab20da70860f027b9eb4 Mon Sep 17 00:00:00 2001 From: badaix Date: Thu, 23 Jan 2025 23:23:18 +0100 Subject: [PATCH 05/37] Cleanup connect --- client/client_connection.cpp | 197 ++++++++++++++++------------------- client/client_connection.hpp | 9 +- 2 files changed, 93 insertions(+), 113 deletions(-) diff --git a/client/client_connection.cpp b/client/client_connection.cpp index 6a716258..8104d2c3 100644 --- a/client/client_connection.cpp +++ b/client/client_connection.cpp @@ -105,6 +105,66 @@ ClientConnection::ClientConnection(boost::asio::io_context& io_context, ClientSe } +void ClientConnection::connect(const ResultHandler& handler) +{ + boost::system::error_code ec; + LOG(INFO, LOG_TAG) << "Resolving host IP for: " << server_.host << "\n"; + auto iterator = resolver_.resolve(server_.host, cpt::to_string(server_.port), boost::asio::ip::resolver_query_base::numeric_service, ec); + if (ec) + { + LOG(ERROR, LOG_TAG) << "Failed to resolve host '" << server_.host << "', error: " << ec.message() << "\n"; + handler(ec); + return; + } + + for (const auto& iter : iterator) + LOG(DEBUG, LOG_TAG) << "Resolved IP: " << iter.endpoint().address().to_string() << "\n"; + + for (const auto& iter : iterator) + { + LOG(INFO, LOG_TAG) << "Connecting to " << iter.endpoint() << "\n"; + ec = doConnect(iter.endpoint()); + if (!ec || (ec == boost::system::errc::interrupted)) + { + // We were successful or interrupted, e.g. by sig int + break; + } + } + + if (ec) + LOG(ERROR, LOG_TAG) << "Failed to connect to host '" << server_.host << "', error: " << ec.message() << "\n"; + else + LOG(NOTICE, LOG_TAG) << "Connected to " << server_.host << "\n"; + + handler(ec); + +#if 0 + resolver_.async_resolve(query, host_, cpt::to_string(port_), [this, handler](const boost::system::error_code& ec, tcp::resolver::results_type results) { + if (ec) + { + LOG(ERROR, LOG_TAG) << "Failed to resolve host '" << host_ << "', error: " << ec.message() << "\n"; + handler(ec); + return; + } + + resolver_.cancel(); + socket_.async_connect(*results, [this, handler](const boost::system::error_code& ec) { + if (ec) + { + LOG(ERROR, LOG_TAG) << "Failed to connect to host '" << host_ << "', error: " << ec.message() << "\n"; + handler(ec); + return; + } + + LOG(NOTICE, LOG_TAG) << "Connected to " << socket_.remote_endpoint().address().to_string() << "\n"; + handler(ec); + getNextMessage(); + }); + }); +#endif +} + + void ClientConnection::sendNext() { auto& message = messages_.front(); @@ -184,64 +244,6 @@ ClientConnectionTcp::~ClientConnectionTcp() disconnect(); } -void ClientConnectionTcp::connect(const ResultHandler& handler) -{ - boost::system::error_code ec; - LOG(INFO, LOG_TAG) << "Resolving host IP for: " << server_.host << "\n"; - auto iterator = resolver_.resolve(server_.host, cpt::to_string(server_.port), boost::asio::ip::resolver_query_base::numeric_service, ec); - if (ec) - { - LOG(ERROR, LOG_TAG) << "Failed to resolve host '" << server_.host << "', error: " << ec.message() << "\n"; - handler(ec); - return; - } - - for (const auto& iter : iterator) - LOG(DEBUG, LOG_TAG) << "Resolved IP: " << iter.endpoint().address().to_string() << "\n"; - - for (const auto& iter : iterator) - { - LOG(INFO, LOG_TAG) << "Connecting to " << iter.endpoint() << "\n"; - socket_.connect(iter, ec); - if (!ec || (ec == boost::system::errc::interrupted)) - { - // We were successful or interrupted, e.g. by sig int - break; - } - } - - if (ec) - LOG(ERROR, LOG_TAG) << "Failed to connect to host '" << server_.host << "', error: " << ec.message() << "\n"; - else - LOG(NOTICE, LOG_TAG) << "Connected to " << socket_.remote_endpoint().address().to_string() << "\n"; - - handler(ec); - -#if 0 - resolver_.async_resolve(query, host_, cpt::to_string(port_), [this, handler](const boost::system::error_code& ec, tcp::resolver::results_type results) { - if (ec) - { - LOG(ERROR, LOG_TAG) << "Failed to resolve host '" << host_ << "', error: " << ec.message() << "\n"; - handler(ec); - return; - } - - resolver_.cancel(); - socket_.async_connect(*results, [this, handler](const boost::system::error_code& ec) { - if (ec) - { - LOG(ERROR, LOG_TAG) << "Failed to connect to host '" << host_ << "', error: " << ec.message() << "\n"; - handler(ec); - return; - } - - LOG(NOTICE, LOG_TAG) << "Connected to " << socket_.remote_endpoint().address().to_string() << "\n"; - handler(ec); - getNextMessage(); - }); - }); -#endif -} void ClientConnectionTcp::disconnect() { @@ -349,6 +351,14 @@ void ClientConnectionTcp::getNextMessage(const MessageHandler& } +boost::system::error_code ClientConnectionTcp::doConnect(boost::asio::ip::basic_endpoint endpoint) +{ + boost::system::error_code ec; + socket_.connect(endpoint, ec); + return ec; +} + + void ClientConnectionTcp::write(boost::asio::streambuf& buffer, WriteHandler&& write_handler) { boost::asio::async_write(socket_, buffer, write_handler); @@ -370,58 +380,6 @@ ClientConnectionWs::~ClientConnectionWs() } -void ClientConnectionWs::connect(const ResultHandler& handler) -{ - boost::system::error_code ec; - LOG(INFO, LOG_TAG) << "Resolving host IP for: " << server_.host << "\n"; - auto iterator = resolver_.resolve(server_.host, cpt::to_string(server_.port), boost::asio::ip::resolver_query_base::numeric_service, ec); - if (ec) - { - LOG(ERROR, LOG_TAG) << "Failed to resolve host '" << server_.host << "', error: " << ec.message() << "\n"; - handler(ec); - return; - } - - for (const auto& iter : iterator) - LOG(DEBUG, LOG_TAG) << "Resolved IP: " << iter.endpoint().address().to_string() << "\n"; - - for (const auto& iter : iterator) - { - LOG(INFO, LOG_TAG) << "Connecting to " << iter.endpoint() << "\n"; - if (tcp_ws_) - { - tcp_ws_->binary(true); - tcp_ws_->next_layer().connect(iter, ec); - - // Set suggested timeout settings for the websocket - tcp_ws_->set_option(websocket::stream_base::timeout::suggested(beast::role_type::client)); - - // Set a decorator to change the User-Agent of the handshake - tcp_ws_->set_option(websocket::stream_base::decorator([](websocket::request_type& req) - { req.set(http::field::user_agent, std::string(BOOST_BEAST_VERSION_STRING) + " websocket-client-async"); })); - - // Perform the websocket handshake - tcp_ws_->handshake("127.0.0.1", "/stream", ec); - handler(ec); - return; - } - - if (!ec || (ec == boost::system::errc::interrupted)) - { - // We were successful or interrupted, e.g. by sig int - break; - } - } - - if (ec) - LOG(ERROR, LOG_TAG) << "Failed to connect to host '" << server_.host << "', error: " << ec.message() << "\n"; - else - LOG(NOTICE, LOG_TAG) << "Connected to " << tcp_ws_->next_layer().remote_endpoint().address().to_string() << "\n"; - - handler(ec); -} - - void ClientConnectionWs::disconnect() { LOG(DEBUG, LOG_TAG) << "Disconnecting\n"; @@ -512,6 +470,25 @@ void ClientConnectionWs::getNextMessage(const MessageHandler& } +boost::system::error_code ClientConnectionWs::doConnect(boost::asio::ip::basic_endpoint endpoint) +{ + boost::system::error_code ec; + tcp_ws_->binary(true); + tcp_ws_->next_layer().connect(endpoint, ec); + + // Set suggested timeout settings for the websocket + tcp_ws_->set_option(websocket::stream_base::timeout::suggested(beast::role_type::client)); + + // Set a decorator to change the User-Agent of the handshake + tcp_ws_->set_option(websocket::stream_base::decorator([](websocket::request_type& req) + { req.set(http::field::user_agent, std::string(BOOST_BEAST_VERSION_STRING) + " websocket-client-async"); })); + + // Perform the websocket handshake + tcp_ws_->handshake("127.0.0.1", "/stream", ec); + return ec; +} + + void ClientConnectionWs::write(boost::asio::streambuf& buffer, WriteHandler&& write_handler) { tcp_ws_->async_write(boost::asio::buffer(buffer.data()), write_handler); diff --git a/client/client_connection.hpp b/client/client_connection.hpp index 94dfb82e..c173b0d4 100644 --- a/client/client_connection.hpp +++ b/client/client_connection.hpp @@ -107,7 +107,7 @@ public: /// async connect /// @param handler async result handler - virtual void connect(const ResultHandler& handler) = 0; + void connect(const ResultHandler& handler); /// disconnect the socket virtual void disconnect() = 0; @@ -147,6 +147,9 @@ public: protected: virtual void write(boost::asio::streambuf& buffer, WriteHandler&& write_handler) = 0; + /// Connect to @p endpoint + virtual boost::system::error_code doConnect(boost::asio::ip::basic_endpoint endpoint) = 0; + /// Send next pending message from messages_ void sendNext(); @@ -195,12 +198,12 @@ public: /// d'tor virtual ~ClientConnectionTcp(); - void connect(const ResultHandler& handler) override; void disconnect() override; std::string getMacAddress() override; void getNextMessage(const MessageHandler& handler) override; private: + boost::system::error_code doConnect(boost::asio::ip::basic_endpoint endpoint) override; void write(boost::asio::streambuf& buffer, WriteHandler&& write_handler) override; /// TCP socket @@ -219,12 +222,12 @@ public: /// d'tor virtual ~ClientConnectionWs(); - void connect(const ResultHandler& handler) override; void disconnect() override; std::string getMacAddress() override; void getNextMessage(const MessageHandler& handler) override; private: + boost::system::error_code doConnect(boost::asio::ip::basic_endpoint endpoint) override; void write(boost::asio::streambuf& buffer, WriteHandler&& write_handler) override; /// SSL web socket From 442b154fbfb1f578df054c99caf53493abea67f1 Mon Sep 17 00:00:00 2001 From: badaix Date: Fri, 24 Jan 2025 23:31:27 +0100 Subject: [PATCH 06/37] Add support for SSL Websockets --- client/CMakeLists.txt | 2 + client/client_connection.cpp | 213 +++++++++++++++++++++++++++-------- client/client_connection.hpp | 32 +++++- client/controller.cpp | 10 +- client/controller.hpp | 1 + 5 files changed, 205 insertions(+), 53 deletions(-) diff --git a/client/CMakeLists.txt b/client/CMakeLists.txt index eefe0845..4f6cf54e 100644 --- a/client/CMakeLists.txt +++ b/client/CMakeLists.txt @@ -62,6 +62,8 @@ elseif(NOT ANDROID) endif(PULSE_FOUND) endif(MACOSX) +list(APPEND CLIENT_LIBRARIES OpenSSL::Crypto OpenSSL::SSL) + if(ANDROID) list(APPEND CLIENT_LIBRARIES oboe::oboe) list(APPEND CLIENT_LIBRARIES boost::boost) diff --git a/client/client_connection.cpp b/client/client_connection.cpp index 8104d2c3..24278bb6 100644 --- a/client/client_connection.cpp +++ b/client/client_connection.cpp @@ -25,16 +25,18 @@ // 3rd party headers #include +#include #include #include #include // standard headers #include +#include #include #include #include -#include +#include #include @@ -43,6 +45,9 @@ namespace http = beast::http; // from static constexpr auto LOG_TAG = "Connection"; +static constexpr const char* WS_CLIENT_NAME = "Snapcast"; + + PendingRequest::PendingRequest(const boost::asio::strand& strand, uint16_t reqId, const MessageHandler& handler) : id_(reqId), timer_(strand), strand_(strand), handler_(handler) { @@ -231,6 +236,28 @@ void ClientConnection::sendRequest(const msg::message_ptr& message, const chrono } +void ClientConnection::messageReceived(std::unique_ptr message, const MessageHandler& handler) +{ + for (auto iter = pendingRequests_.begin(); iter != pendingRequests_.end(); ++iter) + { + auto request = *iter; + if (auto req = request.lock()) + { + if (req->id() == base_message_.refersTo) + { + req->setValue(std::move(message)); + pendingRequests_.erase(iter); + getNextMessage(handler); + return; + } + } + } + + if (handler) + handler({}, std::move(message)); +} + + ///////////////////////////////////// TCP ///////////////////////////////////// ClientConnectionTcp::ClientConnectionTcp(boost::asio::io_context& io_context, ClientSettings::Server server) @@ -329,23 +356,8 @@ void ClientConnectionTcp::getNextMessage(const MessageHandler& auto response = msg::factory::createMessage(base_message_, buffer_.data()); if (!response) LOG(WARNING, LOG_TAG) << "Failed to deserialize message of type: " << base_message_.type << "\n"; - for (auto iter = pendingRequests_.begin(); iter != pendingRequests_.end(); ++iter) - { - auto request = *iter; - if (auto req = request.lock()) - { - if (req->id() == base_message_.refersTo) - { - req->setValue(std::move(response)); - pendingRequests_.erase(iter); - getNextMessage(handler); - return; - } - } - } - if (handler) - handler(ec, std::move(response)); + messageReceived(std::move(response), handler); }); }); } @@ -383,13 +395,13 @@ ClientConnectionWs::~ClientConnectionWs() void ClientConnectionWs::disconnect() { LOG(DEBUG, LOG_TAG) << "Disconnecting\n"; - if (!tcp_ws_->is_open()) + if (!tcp_ws_.is_open()) { LOG(DEBUG, LOG_TAG) << "Not connected\n"; return; } boost::system::error_code ec; - tcp_ws_->close(websocket::close_code::normal, ec); + tcp_ws_.close(websocket::close_code::normal, ec); if (ec) LOG(ERROR, LOG_TAG) << "Error in socket close: " << ec.message() << "\n"; boost::asio::post(strand_, [this]() { pendingRequests_.clear(); }); @@ -401,20 +413,20 @@ std::string ClientConnectionWs::getMacAddress() { std::string mac = #ifndef WINDOWS - ::getMacAddress(tcp_ws_->next_layer().native_handle()); + ::getMacAddress(tcp_ws_.next_layer().native_handle()); #else - ::getMacAddress(tcp_ws_->next_layer().local_endpoint().address().to_string()); + ::getMacAddress(tcp_ws_.next_layer().local_endpoint().address().to_string()); #endif if (mac.empty()) mac = "00:00:00:00:00:00"; - LOG(INFO, LOG_TAG) << "My MAC: \"" << mac << "\", socket: " << tcp_ws_->next_layer().native_handle() << "\n"; + LOG(INFO, LOG_TAG) << "My MAC: \"" << mac << "\", socket: " << tcp_ws_.next_layer().native_handle() << "\n"; return mac; } void ClientConnectionWs::getNextMessage(const MessageHandler& handler) { - tcp_ws_->async_read(buffer_, [this, handler](beast::error_code ec, std::size_t bytes_transferred) mutable + tcp_ws_.async_read(buffer_, [this, handler](beast::error_code ec, std::size_t bytes_transferred) mutable { tv now; LOG(DEBUG, LOG_TAG) << "on_read_ws, ec: " << ec << ", bytes_transferred: " << bytes_transferred << "\n"; @@ -449,23 +461,7 @@ void ClientConnectionWs::getNextMessage(const MessageHandler& LOG(DEBUG, LOG_TAG) << "getNextMessage: " << response->type << ", size: " << response->size << ", id: " << response->id << ", refers: " << response->refersTo << "\n"; - for (auto iter = pendingRequests_.begin(); iter != pendingRequests_.end(); ++iter) - { - auto request = *iter; - if (auto req = request.lock()) - { - if (req->id() == base_message_.refersTo) - { - req->setValue(std::move(response)); - pendingRequests_.erase(iter); - getNextMessage(handler); - return; - } - } - } - - if (handler) - handler(ec, std::move(response)); + messageReceived(std::move(response), handler); }); } @@ -473,23 +469,146 @@ void ClientConnectionWs::getNextMessage(const MessageHandler& boost::system::error_code ClientConnectionWs::doConnect(boost::asio::ip::basic_endpoint endpoint) { boost::system::error_code ec; - tcp_ws_->binary(true); - tcp_ws_->next_layer().connect(endpoint, ec); + tcp_ws_.binary(true); + tcp_ws_.next_layer().connect(endpoint, ec); // Set suggested timeout settings for the websocket - tcp_ws_->set_option(websocket::stream_base::timeout::suggested(beast::role_type::client)); + tcp_ws_.set_option(websocket::stream_base::timeout::suggested(beast::role_type::client)); // Set a decorator to change the User-Agent of the handshake - tcp_ws_->set_option(websocket::stream_base::decorator([](websocket::request_type& req) - { req.set(http::field::user_agent, std::string(BOOST_BEAST_VERSION_STRING) + " websocket-client-async"); })); + tcp_ws_.set_option(websocket::stream_base::decorator([](websocket::request_type& req) { req.set(http::field::user_agent, WS_CLIENT_NAME); })); // Perform the websocket handshake - tcp_ws_->handshake("127.0.0.1", "/stream", ec); + tcp_ws_.handshake(server_.host + ":" + std::to_string(server_.port), "/stream", ec); return ec; } void ClientConnectionWs::write(boost::asio::streambuf& buffer, WriteHandler&& write_handler) { - tcp_ws_->async_write(boost::asio::buffer(buffer.data()), write_handler); + tcp_ws_.async_write(boost::asio::buffer(buffer.data()), write_handler); +} + + + +/////////////////////////////// SSL Websockets //////////////////////////////// + + +ClientConnectionWss::ClientConnectionWss(boost::asio::io_context& io_context, boost::asio::ssl::context& ssl_context, ClientSettings::Server server) + : ClientConnection(io_context, std::move(server)), ssl_ws_(strand_, ssl_context) +{ +} + + +ClientConnectionWss::~ClientConnectionWss() +{ + disconnect(); +} + + +void ClientConnectionWss::disconnect() +{ + LOG(DEBUG, LOG_TAG) << "Disconnecting\n"; + if (!ssl_ws_.is_open()) + { + LOG(DEBUG, LOG_TAG) << "Not connected\n"; + return; + } + boost::system::error_code ec; + ssl_ws_.close(websocket::close_code::normal, ec); + if (ec) + LOG(ERROR, LOG_TAG) << "Error in socket close: " << ec.message() << "\n"; + boost::asio::post(strand_, [this]() { pendingRequests_.clear(); }); + LOG(DEBUG, LOG_TAG) << "Disconnected\n"; +} + + +std::string ClientConnectionWss::getMacAddress() +{ + std::string mac = +#ifndef WINDOWS + ::getMacAddress(ssl_ws_.next_layer().lowest_layer().native_handle()); +#else + ::getMacAddress(tcp_ws_.next_layer().lowest_layer().local_endpoint().address().to_string()); +#endif + if (mac.empty()) + mac = "00:00:00:00:00:00"; + LOG(INFO, LOG_TAG) << "My MAC: \"" << mac << "\", socket: " << ssl_ws_.next_layer().lowest_layer().native_handle() << "\n"; + return mac; +} + + +void ClientConnectionWss::getNextMessage(const MessageHandler& handler) +{ + ssl_ws_.async_read(buffer_, [this, handler](beast::error_code ec, std::size_t bytes_transferred) mutable + { + tv now; + LOG(DEBUG, LOG_TAG) << "on_read_ws, ec: " << ec << ", bytes_transferred: " << bytes_transferred << "\n"; + + // This indicates that the session was closed + if (ec == websocket::error::closed) + { + if (handler) + handler(ec, nullptr); + return; + } + + if (ec) + { + LOG(ERROR, LOG_TAG) << "ControlSessionWebsocket::on_read_ws error: " << ec.message() << "\n"; + if (handler) + handler(ec, nullptr); + return; + } + + buffer_.consume(bytes_transferred); + + auto* data = static_cast(buffer_.data().data()); + base_message_.deserialize(data); + + base_message_.received = now; + + auto response = msg::factory::createMessage(base_message_, data + base_msg_size_); + if (!response) + LOG(WARNING, LOG_TAG) << "Failed to deserialize message of type: " << base_message_.type << "\n"; + else + LOG(DEBUG, LOG_TAG) << "getNextMessage: " << response->type << ", size: " << response->size << ", id: " << response->id + << ", refers: " << response->refersTo << "\n"; + + messageReceived(std::move(response), handler); + }); +} + + +boost::system::error_code ClientConnectionWss::doConnect(boost::asio::ip::basic_endpoint endpoint) +{ + boost::system::error_code ec; + ssl_ws_.binary(true); + beast::get_lowest_layer(ssl_ws_).connect(endpoint, ec); + + // Set a timeout on the operation + // beast::get_lowest_layer(ssl_ws_).expires_after(std::chrono::seconds(30)); + + // Set suggested timeout settings for the websocket + // ssl_ws_.set_option(websocket::stream_base::timeout::suggested(beast::role_type::client)); + + // Set SNI Hostname (many hosts need this to handshake successfully) + if (!SSL_set_tlsext_host_name(ssl_ws_.next_layer().native_handle(), server_.host.c_str())) + throw beast::system_error(beast::error_code(static_cast(::ERR_get_error()), boost::asio::error::get_ssl_category()), "Failed to set SNI Hostname"); + + // Perform the SSL handshake + ssl_ws_.next_layer().handshake(boost::asio::ssl::stream_base::client); + + // Set a decorator to change the User-Agent of the handshake + ssl_ws_.set_option(websocket::stream_base::decorator([](websocket::request_type& req) { req.set(http::field::user_agent, WS_CLIENT_NAME); })); + + // Perform the websocket handshake + ssl_ws_.handshake(server_.host + ":" + std::to_string(server_.port), "/stream", ec); + return ec; +} + + +void ClientConnectionWss::write(boost::asio::streambuf& buffer, WriteHandler&& write_handler) +{ + ssl_ws_.async_write(boost::asio::buffer(buffer.data()), write_handler); } diff --git a/client/client_connection.hpp b/client/client_connection.hpp index c173b0d4..43076029 100644 --- a/client/client_connection.hpp +++ b/client/client_connection.hpp @@ -150,6 +150,9 @@ protected: /// Connect to @p endpoint virtual boost::system::error_code doConnect(boost::asio::ip::basic_endpoint endpoint) = 0; + /// Handle received messages, check for response of pending requests + void messageReceived(std::unique_ptr message, const MessageHandler& handler); + /// Send next pending message from messages_ void sendNext(); @@ -230,10 +233,33 @@ private: boost::system::error_code doConnect(boost::asio::ip::basic_endpoint endpoint) override; void write(boost::asio::streambuf& buffer, WriteHandler&& write_handler) override; - /// SSL web socket - // std::optional ssl_ws_; /// TCP web socket - std::optional tcp_ws_; + tcp_websocket tcp_ws_; + /// Receive buffer + boost::beast::flat_buffer buffer_; +}; + + + +/// Websocket connection +class ClientConnectionWss : public ClientConnection +{ +public: + /// c'tor + ClientConnectionWss(boost::asio::io_context& io_context, boost::asio::ssl::context& ssl_context, ClientSettings::Server server); + /// d'tor + virtual ~ClientConnectionWss(); + + void disconnect() override; + std::string getMacAddress() override; + void getNextMessage(const MessageHandler& handler) override; + +private: + boost::system::error_code doConnect(boost::asio::ip::basic_endpoint endpoint) override; + void write(boost::asio::streambuf& buffer, WriteHandler&& write_handler) override; + + /// SSL web socket + ssl_websocket ssl_ws_; /// Receive buffer boost::beast::flat_buffer buffer_; }; diff --git a/client/controller.cpp b/client/controller.cpp index add7eb8c..8bdd359c 100644 --- a/client/controller.cpp +++ b/client/controller.cpp @@ -76,10 +76,12 @@ using namespace player; static constexpr auto LOG_TAG = "Controller"; static constexpr auto TIME_SYNC_INTERVAL = 1s; -Controller::Controller(boost::asio::io_context& io_context, const ClientSettings& settings) //, std::unique_ptr meta) - : io_context_(io_context), timer_(io_context), settings_(settings), stream_(nullptr), decoder_(nullptr), player_(nullptr), - serverSettings_(nullptr) // meta_(std::move(meta)), +Controller::Controller(boost::asio::io_context& io_context, const ClientSettings& settings) + : io_context_(io_context), ssl_context_(boost::asio::ssl::context::tlsv12_client), timer_(io_context), settings_(settings), stream_(nullptr), + decoder_(nullptr), player_(nullptr), serverSettings_(nullptr) { + // TODO: Load and verify certificate + // ssl_context_.load_verify_file("/home/johannes/Develop/snapcast/server/etc/certs/snapcastCA.crt"); } @@ -363,6 +365,8 @@ void Controller::start() { if (settings_.server.protocol == "ws") clientConnection_ = make_unique(io_context_, settings_.server); + else if (settings_.server.protocol == "wss") + clientConnection_ = make_unique(io_context_, ssl_context_, settings_.server); else clientConnection_ = make_unique(io_context_, settings_.server); worker(); diff --git a/client/controller.hpp b/client/controller.hpp index ba605c0f..48f9b157 100644 --- a/client/controller.hpp +++ b/client/controller.hpp @@ -64,6 +64,7 @@ private: void sendTimeSyncMessage(int quick_syncs); boost::asio::io_context& io_context_; + boost::asio::ssl::context ssl_context_; boost::asio::steady_timer timer_; ClientSettings settings_; SampleFormat sampleFormat_; From b0463fdd0c05c07ffe77cfd651c6c53ffa4668f6 Mon Sep 17 00:00:00 2001 From: badaix Date: Fri, 24 Jan 2025 23:36:51 +0100 Subject: [PATCH 07/37] Fix Windows compile error --- .github/workflows/ci.yml | 2 +- client/client_connection.cpp | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e61b41d..ede6b996 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,7 +75,7 @@ jobs: mkdir -p build/doxygen doxygen 2>&1 | tee build/doxygen.log WARNINGS=$(cat build/doxygen.log | sort | uniq | grep -e ": warning: " | wc -l) - MAX_ALLOWED=724 + MAX_ALLOWED=693 echo "Doxygen finished with $WARNINGS warnings, max allowed: $MAX_ALLOWED" if [ "$WARNINGS" -gt "$MAX_ALLOWED" ]; then exit $WARNINGS; else exit 0; fi; diff --git a/client/client_connection.cpp b/client/client_connection.cpp index 24278bb6..22602ed4 100644 --- a/client/client_connection.cpp +++ b/client/client_connection.cpp @@ -529,7 +529,7 @@ std::string ClientConnectionWss::getMacAddress() #ifndef WINDOWS ::getMacAddress(ssl_ws_.next_layer().lowest_layer().native_handle()); #else - ::getMacAddress(tcp_ws_.next_layer().lowest_layer().local_endpoint().address().to_string()); + ::getMacAddress(ssl_ws_.next_layer().lowest_layer().local_endpoint().address().to_string()); #endif if (mac.empty()) mac = "00:00:00:00:00:00"; From d7ddfc8b885873fa165f68d698a204f17f04c743 Mon Sep 17 00:00:00 2001 From: badaix Date: Sat, 25 Jan 2025 18:02:30 +0100 Subject: [PATCH 08/37] Allow warnings for sanitizers --- .github/workflows/ci.yml | 2 +- CMakeLists.txt | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ede6b996..3eed82be 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -129,7 +129,7 @@ jobs: - name: configure run: | cmake -S . -B build \ - -DWERROR=ON \ + -DWERROR=OFF \ -DBUILD_TESTS=ON \ -D${{ matrix.param }} \ -DBOOST_ROOT=boost_${BOOST_VERSION} \ diff --git a/CMakeLists.txt b/CMakeLists.txt index 66d76515..57380fb3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -69,12 +69,12 @@ else() endif() if(ASAN) - add_compile_options(-fsanitize=address -Wno-error=maybe-uninitialized) + add_compile_options(-fsanitize=address) add_link_options(-fsanitize=address) endif() if(TSAN) - add_compile_options(-fsanitize=thread -Wno-error=tsan) + add_compile_options(-fsanitize=thread) add_link_options(-fsanitize=thread) endif() From 3d5744c6b0e9087be565230cbe4092e83b3af96a Mon Sep 17 00:00:00 2001 From: badaix Date: Sat, 25 Jan 2025 18:03:13 +0100 Subject: [PATCH 09/37] Fix crash --- client/client_connection.cpp | 6 +++--- client/client_connection.hpp | 2 ++ client/snapclient.cpp | 21 +++++++++++++-------- 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/client/client_connection.cpp b/client/client_connection.cpp index 22602ed4..c071b962 100644 --- a/client/client_connection.cpp +++ b/client/client_connection.cpp @@ -173,15 +173,15 @@ void ClientConnection::connect(const ResultHandler& handler) void ClientConnection::sendNext() { auto& message = messages_.front(); - boost::asio::streambuf streambuf; - std::ostream stream(&streambuf); + std::ostream stream(&streambuf_); tv t; message.msg->sent = t; message.msg->serialize(stream); ResultHandler handler = message.handler; - write(streambuf, [this, handler](boost::system::error_code ec, std::size_t length) + write(streambuf_, [this, handler](boost::system::error_code ec, std::size_t length) { + streambuf_.consume(length); if (ec) LOG(ERROR, LOG_TAG) << "Failed to send message, error: " << ec.message() << "\n"; else diff --git a/client/client_connection.hpp b/client/client_connection.hpp index 43076029..ef38777f 100644 --- a/client/client_connection.hpp +++ b/client/client_connection.hpp @@ -173,6 +173,8 @@ protected: ClientSettings::Server server_; /// Size of a base message (= message header) const size_t base_msg_size_; + /// Send stream buffer + boost::asio::streambuf streambuf_; /// A pending request struct PendingMessage diff --git a/client/snapclient.cpp b/client/snapclient.cpp index e5594b25..7ad9e681 100644 --- a/client/snapclient.cpp +++ b/client/snapclient.cpp @@ -204,14 +204,6 @@ int main(int argc, char** argv) exit(EXIT_FAILURE); } - if (!op.non_option_args().empty()) - { - streamreader::StreamUri uri(op.non_option_args().front()); - settings.server.host = uri.host; - settings.server.port = uri.port.value_or(settings.server.port); - settings.server.protocol = uri.scheme; - } - if (versionSwitch->is_set()) { cout << "snapclient v" << version::code << (!version::rev().empty() ? (" (rev " + version::rev(8) + ")") : ("")) << "\n" @@ -313,6 +305,19 @@ int main(int argc, char** argv) else throw SnapException("Invalid log sink: " + settings.logging.sink); + if (!op.non_option_args().empty()) + { + streamreader::StreamUri uri(op.non_option_args().front()); + settings.server.host = uri.host; + settings.server.protocol = uri.scheme; + if (uri.port.has_value()) + settings.server.port = uri.port.value(); + else if (settings.server.protocol == "ws") + settings.server.port = 1780; + else if (settings.server.protocol == "wss") + settings.server.port = 1788; + } + #if !defined(HAS_AVAHI) && !defined(HAS_BONJOUR) if (settings.server.host.empty()) throw SnapException("Snapserver host not configured and mDNS not available, please configure with \"--host\"."); From b20bd90c036a2328a15ccb09fbbc5f288242ae8e Mon Sep 17 00:00:00 2001 From: badaix Date: Sat, 25 Jan 2025 22:37:08 +0100 Subject: [PATCH 10/37] Verify server certificate --- client/client_connection.cpp | 21 ++++++++++++++++ client/client_settings.hpp | 9 +++++++ client/controller.cpp | 15 ++++++++++-- client/snapclient.cpp | 47 ++++++++++++++++++++++++------------ common/popl.hpp | 4 +-- 5 files changed, 77 insertions(+), 19 deletions(-) diff --git a/client/client_connection.cpp b/client/client_connection.cpp index c071b962..ee37f201 100644 --- a/client/client_connection.cpp +++ b/client/client_connection.cpp @@ -497,6 +497,27 @@ void ClientConnectionWs::write(boost::asio::streambuf& buffer, WriteHandler&& wr ClientConnectionWss::ClientConnectionWss(boost::asio::io_context& io_context, boost::asio::ssl::context& ssl_context, ClientSettings::Server server) : ClientConnection(io_context, std::move(server)), ssl_ws_(strand_, ssl_context) { + if (server.certificate.has_value()) + { + ssl_ws_.next_layer().set_verify_mode(boost::asio::ssl::verify_peer); + ssl_ws_.next_layer().set_verify_callback([](bool preverified, boost::asio::ssl::verify_context& ctx) + { + // The verify callback can be used to check whether the certificate that is + // being presented is valid for the peer. For example, RFC 2818 describes + // the steps involved in doing this for HTTPS. Consult the OpenSSL + // documentation for more details. Note that the callback is called once + // for each certificate in the certificate chain, starting from the root + // certificate authority. + + // In this example we will simply print the certificate's subject name. + char subject_name[256]; + X509* cert = X509_STORE_CTX_get_current_cert(ctx.native_handle()); + X509_NAME_oneline(X509_get_subject_name(cert), subject_name, 256); + LOG(INFO, LOG_TAG) << "verifying cert: '" << subject_name << "', pre verified: " << preverified << "\n"; + + return preverified; + }); + } } diff --git a/client/client_settings.hpp b/client/client_settings.hpp index 5ea4adc0..7d63448a 100644 --- a/client/client_settings.hpp +++ b/client/client_settings.hpp @@ -23,6 +23,8 @@ #include "player/pcm_device.hpp" // standard headers +#include +#include #include @@ -64,6 +66,13 @@ struct ClientSettings std::string protocol; /// server port size_t port{1704}; + /// server certificate + std::optional certificate; + /// Is ssl in use? + bool isSsl() const + { + return (protocol == "wss"); + } }; /// The audio player (DAC) diff --git a/client/controller.cpp b/client/controller.cpp index 8bdd359c..b8d35a94 100644 --- a/client/controller.cpp +++ b/client/controller.cpp @@ -80,8 +80,19 @@ Controller::Controller(boost::asio::io_context& io_context, const ClientSettings : io_context_(io_context), ssl_context_(boost::asio::ssl::context::tlsv12_client), timer_(io_context), settings_(settings), stream_(nullptr), decoder_(nullptr), player_(nullptr), serverSettings_(nullptr) { - // TODO: Load and verify certificate - // ssl_context_.load_verify_file("/home/johannes/Develop/snapcast/server/etc/certs/snapcastCA.crt"); + if (settings.server.isSsl() && settings.server.certificate.has_value()) + { + boost::system::error_code ec; + ssl_context_.set_default_verify_paths(ec); + if (ec.failed()) + LOG(WARNING, LOG_TAG) << "Failed to load system certificates: " << ec << "\n"; + if (!settings.server.certificate->empty()) + { + ssl_context_.load_verify_file(settings.server.certificate.value(), ec); + if (ec.failed()) + throw SnapException("Failed to load certificate: " + settings.server.certificate.value().native() + ": " + ec.message()); + } + } } diff --git a/client/snapclient.cpp b/client/snapclient.cpp index 7ad9e681..d67d05ad 100644 --- a/client/snapclient.cpp +++ b/client/snapclient.cpp @@ -46,6 +46,7 @@ #include // standard headers +#include #include #ifndef WINDOWS #include @@ -135,23 +136,27 @@ int main(int argc, char** argv) ClientSettings settings; string pcm_device(player::DEFAULT_DEVICE); - OptionParser op("Allowed options"); - auto helpSwitch = op.add("", "help", "produce help message"); - auto groffSwitch = op.add("", "groff", "produce groff message"); - auto versionSwitch = op.add("v", "version", "show version number"); - op.add>("h", "host", "server hostname or ip address", "", &settings.server.host); - op.add>("p", "port", "server port", 1704, &settings.server.port); - op.add>("i", "instance", "instance id when running multiple instances on the same host", 1, &settings.instance); - op.add>("", "hostID", "unique host id, default is MAC address", "", &settings.host_id); + OptionParser op("Usage: snapclient [options...] [url]\n\n" + " With 'url' = ://[:port]\n" + " For example: \"tcp:\\\\192.168.1.1:1704\", or \"wss:\\\\homeserver.local\"\n" + " If 'url' is not configured, snapclient tries to resolve the snapserver IP via mDNS\n"); + auto helpSwitch = op.add("", "help", "Produce help message"); + auto groffSwitch = op.add("", "groff", "Produce groff message"); + auto versionSwitch = op.add("v", "version", "Show version number"); + op.add>("h", "host", "(deprecated, use [url]) Server hostname or ip address", "", &settings.server.host); + op.add>("p", "port", "(deprecated, use [url]) Server port", 1704, &settings.server.port); + op.add>("i", "instance", "Instance id when running multiple instances on the same host", 1, &settings.instance); + op.add>("", "hostID", "Unique host id, default is MAC address", "", &settings.host_id); + auto server_cert_opt = op.add>("", "server-cert", "Verify server with certificate", "default certificates"); // PCM device specific #if defined(HAS_ALSA) || defined(HAS_PULSE) || defined(HAS_WASAPI) - auto listSwitch = op.add("l", "list", "list PCM devices"); - /*auto soundcardValue =*/op.add>("s", "soundcard", "index or name of the pcm device", pcm_device, &pcm_device); + auto listSwitch = op.add("l", "list", "List PCM devices"); + /*auto soundcardValue =*/op.add>("s", "Soundcard", "index or name of the pcm device", pcm_device, &pcm_device); #endif - /*auto latencyValue =*/op.add>("", "latency", "latency of the PCM device", 0, &settings.player.latency); + /*auto latencyValue =*/op.add>("", "Latency", "latency of the PCM device", 0, &settings.player.latency); #ifdef HAS_SOXR - auto sample_format = op.add>("", "sampleformat", "resample audio stream to ::", ""); + auto sample_format = op.add>("", "sampleformat", "Resample audio stream to ::", ""); #endif auto supported_players = Controller::getSupportedPlayerNames(); @@ -162,7 +167,7 @@ int main(int argc, char** argv) // sharing mode #if defined(HAS_OBOE) || defined(HAS_WASAPI) - auto sharing_mode = op.add>("", "sharingmode", "audio mode to use [shared|exclusive]", "shared"); + auto sharing_mode = op.add>("", "sharingmode", "Audio mode to use [shared|exclusive]", "shared"); #endif // mixer @@ -183,12 +188,12 @@ int main(int argc, char** argv) // daemon settings #ifdef HAS_DAEMON int processPriority(-3); - auto daemonOption = op.add>("d", "daemon", "daemonize, optional process priority [-20..19]", processPriority, &processPriority); + auto daemonOption = op.add>("d", "daemon", "Daemonize, optional process priority [-20..19]", processPriority, &processPriority); auto userValue = op.add>("", "user", "the user[:group] to run snapclient as when daemonized"); #endif // logging - op.add>("", "logsink", "log sink [null,system,stdout,stderr,file:]", settings.logging.sink, &settings.logging.sink); + op.add>("", "logsink", "Log sink [null,system,stdout,stderr,file:]", settings.logging.sink, &settings.logging.sink); auto logfilterOption = op.add>( "", "logfilter", "log filter :[,:]* with tag = * or and level = [trace,debug,info,notice,warning,error,fatal]", settings.logging.filter); @@ -318,6 +323,18 @@ int main(int argc, char** argv) settings.server.port = 1788; } + if (server_cert_opt->is_set()) + { + if (server_cert_opt->get_default() == server_cert_opt->value()) + settings.server.certificate = ""; + else + settings.server.certificate = std::filesystem::weakly_canonical(server_cert_opt->value()); + if (settings.server.certificate.value_or("").empty()) + LOG(INFO, LOG_TAG) << "Server certificate: default certificates\n"; + else + LOG(INFO, LOG_TAG) << "Server certificate: " << settings.server.certificate.value_or("") << "\n"; + } + #if !defined(HAS_AVAHI) && !defined(HAS_BONJOUR) if (settings.server.host.empty()) throw SnapException("Snapserver host not configured and mDNS not available, please configure with \"--host\"."); diff --git a/common/popl.hpp b/common/popl.hpp index 40c35cc3..6ee354fe 100644 --- a/common/popl.hpp +++ b/common/popl.hpp @@ -3,7 +3,7 @@ ( _ \ / \( _ \( ) ) __/( O )) __// (_/\ (__) \__/(__) \____/ - version 1.3.0 + version 1.3.1 https://github.com/badaix/popl This file is part of popl (program options parser lib) @@ -1167,7 +1167,7 @@ inline std::string ConsoleOptionPrinter::print(const Attribute& max_attribute) c std::stringstream s; if (!option_parser_->description().empty()) - s << option_parser_->description() << ":\n"; + s << option_parser_->description() << "\n"; size_t optionRightMargin(20); const size_t maxDescriptionLeftMargin(40); From 23107d62f9b11ead23815b9cb9501ddd66b2310c Mon Sep 17 00:00:00 2001 From: badaix Date: Sat, 25 Jan 2025 22:47:23 +0100 Subject: [PATCH 11/37] Set client option --- client/client_connection.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/client_connection.cpp b/client/client_connection.cpp index ee37f201..2d1a4269 100644 --- a/client/client_connection.cpp +++ b/client/client_connection.cpp @@ -611,7 +611,7 @@ boost::system::error_code ClientConnectionWss::doConnect(boost::asio::ip::basic_ // beast::get_lowest_layer(ssl_ws_).expires_after(std::chrono::seconds(30)); // Set suggested timeout settings for the websocket - // ssl_ws_.set_option(websocket::stream_base::timeout::suggested(beast::role_type::client)); + ssl_ws_.set_option(websocket::stream_base::timeout::suggested(beast::role_type::client)); // Set SNI Hostname (many hosts need this to handshake successfully) if (!SSL_set_tlsext_host_name(ssl_ws_.next_layer().native_handle(), server_.host.c_str())) From 054706e608944cbd107dc961d1419641877f5538 Mon Sep 17 00:00:00 2001 From: badaix Date: Sun, 26 Jan 2025 22:37:59 +0100 Subject: [PATCH 12/37] Add deprecation notice --- changelog.md | 16 ++++++++++++++++ client/snapclient.cpp | 29 ++++++++++++++++++++++++++--- common/stream_uri.cpp | 4 ---- common/stream_uri.hpp | 7 ++----- test/test_main.cpp | 1 - 5 files changed, 44 insertions(+), 13 deletions(-) diff --git a/changelog.md b/changelog.md index 1ae2bd99..6d41052e 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,21 @@ # Snapcast changelog +## Version 0.32.0 + +### Features + +- Client: Add support for (secure-) websockets (Issue #1325) + +### Bugfixes + +- Fix typo in documentation (PR #1333) + +### General + +- Client: Command line arguments '--host' and '--port' are deprecated + +_Johannes Pohl Thu, 23 Jan 2025 00:13:37 +0200_ + ## Version 0.31.0 ### Features diff --git a/client/snapclient.cpp b/client/snapclient.cpp index d67d05ad..281c3f5f 100644 --- a/client/snapclient.cpp +++ b/client/snapclient.cpp @@ -143,8 +143,8 @@ int main(int argc, char** argv) auto helpSwitch = op.add("", "help", "Produce help message"); auto groffSwitch = op.add("", "groff", "Produce groff message"); auto versionSwitch = op.add("v", "version", "Show version number"); - op.add>("h", "host", "(deprecated, use [url]) Server hostname or ip address", "", &settings.server.host); - op.add>("p", "port", "(deprecated, use [url]) Server port", 1704, &settings.server.port); + auto host_opt = op.add>("h", "host", "(deprecated, use [url]) Server hostname or ip address", "", &settings.server.host); + auto port_opt = op.add>("p", "port", "(deprecated, use [url]) Server port", 1704, &settings.server.port); op.add>("i", "instance", "Instance id when running multiple instances on the same host", 1, &settings.instance); op.add>("", "hostID", "Unique host id, default is MAC address", "", &settings.host_id); auto server_cert_opt = op.add>("", "server-cert", "Verify server with certificate", "default certificates"); @@ -310,13 +310,36 @@ int main(int argc, char** argv) else throw SnapException("Invalid log sink: " + settings.logging.sink); + if (!op.unknown_options().empty()) + { + throw SnapException("Unknown command line argument: '" + op.unknown_options().front() + "'"); + } + + if (host_opt->is_set() || port_opt->is_set()) + { + LOG(WARNING, LOG_TAG) << "Options '--" << host_opt->long_name() << "' and '--" << port_opt->long_name() + << "' are deprecated. Please add the server URI as last command line argument\n"; + } + if (!op.non_option_args().empty()) { - streamreader::StreamUri uri(op.non_option_args().front()); + StreamUri uri; + try + { + uri.parse(op.non_option_args().front()); + } + catch (...) + { + throw SnapException("Invalid URI - expected format: \"://[:port]\", with 'scheme' on of 'tcp', 'ws' or 'wss'"); + } + if ((uri.scheme != "tcp") && (uri.scheme != "ws") && (uri.scheme != "wss")) + throw SnapException("Protocol must be one of 'tcp', 'ws' or 'wss'"); settings.server.host = uri.host; settings.server.protocol = uri.scheme; if (uri.port.has_value()) settings.server.port = uri.port.value(); + else if (settings.server.protocol == "tcp") + settings.server.port = 1704; else if (settings.server.protocol == "ws") settings.server.port = 1780; else if (settings.server.protocol == "wss") diff --git a/common/stream_uri.cpp b/common/stream_uri.cpp index d3409d0a..350abf25 100644 --- a/common/stream_uri.cpp +++ b/common/stream_uri.cpp @@ -34,8 +34,6 @@ namespace strutils = utils::string; static constexpr auto LOG_TAG = "StreamUri"; -namespace streamreader -{ StreamUri::StreamUri(const std::string& uri) { @@ -179,5 +177,3 @@ bool StreamUri::operator==(const StreamUri& other) const return (other.scheme == scheme) && (other.host == host) && (other.port == port) && (other.path == path) && (other.query == query) && (other.fragment == fragment); } - -} // namespace streamreader diff --git a/common/stream_uri.hpp b/common/stream_uri.hpp index fce5bbc4..ebeb94bd 100644 --- a/common/stream_uri.hpp +++ b/common/stream_uri.hpp @@ -30,13 +30,12 @@ using json = nlohmann::json; -namespace streamreader -{ - /// URI with the general format: /// scheme:[//[user:password@]host[:port]][/]path[?query][#fragment] struct StreamUri { + /// c'tor + StreamUri() = default; /// c'tor construct from string @p uri explicit StreamUri(const std::string& uri); @@ -79,5 +78,3 @@ struct StreamUri /// @return true if @p other is equal to this bool operator==(const StreamUri& other) const; }; - -} // namespace streamreader diff --git a/test/test_main.cpp b/test/test_main.cpp index 9023403c..8af0a863 100644 --- a/test/test_main.cpp +++ b/test/test_main.cpp @@ -224,7 +224,6 @@ TEST_CASE("JWT") TEST_CASE("Uri") { - using namespace streamreader; StreamUri uri("pipe:///tmp/snapfifo?name=default&codec=flac"); REQUIRE(uri.scheme == "pipe"); REQUIRE(uri.path == "/tmp/snapfifo"); From a407e68df6140271e752cfdb7b7c43b246afe53b Mon Sep 17 00:00:00 2001 From: badaix Date: Mon, 27 Jan 2025 10:34:13 +0100 Subject: [PATCH 13/37] Fix wss reconnect --- client/client_connection.cpp | 140 +++++++++++++++++++++++------------ client/client_connection.hpp | 18 ++++- common/snap_exception.hpp | 8 +- 3 files changed, 112 insertions(+), 54 deletions(-) diff --git a/client/client_connection.cpp b/client/client_connection.cpp index 2d1a4269..e046f115 100644 --- a/client/client_connection.cpp +++ b/client/client_connection.cpp @@ -29,13 +29,15 @@ #include #include #include - -// standard headers #include #include #include + +// standard headers #include #include +#include +#include #include #include @@ -137,7 +139,10 @@ void ClientConnection::connect(const ResultHandler& handler) } if (ec) + { LOG(ERROR, LOG_TAG) << "Failed to connect to host '" << server_.host << "', error: " << ec.message() << "\n"; + disconnect(); + } else LOG(NOTICE, LOG_TAG) << "Connected to " << server_.host << "\n"; @@ -392,19 +397,33 @@ ClientConnectionWs::~ClientConnectionWs() } +tcp_websocket& ClientConnectionWs::getWs() +{ + std::lock_guard lock(ws_mutex_); + if (tcp_ws_.has_value()) + return tcp_ws_.value(); + + tcp_ws_.emplace(strand_); + return tcp_ws_.value(); +} + + void ClientConnectionWs::disconnect() { LOG(DEBUG, LOG_TAG) << "Disconnecting\n"; - if (!tcp_ws_.is_open()) - { - LOG(DEBUG, LOG_TAG) << "Not connected\n"; - return; - } boost::system::error_code ec; - tcp_ws_.close(websocket::close_code::normal, ec); - if (ec) - LOG(ERROR, LOG_TAG) << "Error in socket close: " << ec.message() << "\n"; + + if (getWs().is_open()) + getWs().close(websocket::close_code::normal, ec); + // if (ec) + // LOG(ERROR, LOG_TAG) << "Error in socket close: " << ec.message() << "\n"; + if (getWs().next_layer().is_open()) + { + getWs().next_layer().shutdown(boost::asio::ip::tcp::socket::shutdown_both, ec); + getWs().next_layer().close(ec); + } boost::asio::post(strand_, [this]() { pendingRequests_.clear(); }); + tcp_ws_ = std::nullopt; LOG(DEBUG, LOG_TAG) << "Disconnected\n"; } @@ -413,23 +432,23 @@ std::string ClientConnectionWs::getMacAddress() { std::string mac = #ifndef WINDOWS - ::getMacAddress(tcp_ws_.next_layer().native_handle()); + ::getMacAddress(getWs().next_layer().native_handle()); #else - ::getMacAddress(tcp_ws_.next_layer().local_endpoint().address().to_string()); + ::getMacAddress(getWs().next_layer().local_endpoint().address().to_string()); #endif if (mac.empty()) mac = "00:00:00:00:00:00"; - LOG(INFO, LOG_TAG) << "My MAC: \"" << mac << "\", socket: " << tcp_ws_.next_layer().native_handle() << "\n"; + LOG(INFO, LOG_TAG) << "My MAC: \"" << mac << "\", socket: " << getWs().next_layer().native_handle() << "\n"; return mac; } void ClientConnectionWs::getNextMessage(const MessageHandler& handler) { - tcp_ws_.async_read(buffer_, [this, handler](beast::error_code ec, std::size_t bytes_transferred) mutable + getWs().async_read(buffer_, [this, handler](beast::error_code ec, std::size_t bytes_transferred) mutable { tv now; - LOG(DEBUG, LOG_TAG) << "on_read_ws, ec: " << ec << ", bytes_transferred: " << bytes_transferred << "\n"; + LOG(TRACE, LOG_TAG) << "on_read_ws, ec: " << ec << ", bytes_transferred: " << bytes_transferred << "\n"; // This indicates that the session was closed if (ec == websocket::error::closed) @@ -458,7 +477,7 @@ void ClientConnectionWs::getNextMessage(const MessageHandler& if (!response) LOG(WARNING, LOG_TAG) << "Failed to deserialize message of type: " << base_message_.type << "\n"; else - LOG(DEBUG, LOG_TAG) << "getNextMessage: " << response->type << ", size: " << response->size << ", id: " << response->id + LOG(TRACE, LOG_TAG) << "getNextMessage: " << response->type << ", size: " << response->size << ", id: " << response->id << ", refers: " << response->refersTo << "\n"; messageReceived(std::move(response), handler); @@ -469,24 +488,26 @@ void ClientConnectionWs::getNextMessage(const MessageHandler& boost::system::error_code ClientConnectionWs::doConnect(boost::asio::ip::basic_endpoint endpoint) { boost::system::error_code ec; - tcp_ws_.binary(true); - tcp_ws_.next_layer().connect(endpoint, ec); + getWs().binary(true); + getWs().next_layer().connect(endpoint, ec); + if (ec.failed()) + return ec; // Set suggested timeout settings for the websocket - tcp_ws_.set_option(websocket::stream_base::timeout::suggested(beast::role_type::client)); + getWs().set_option(websocket::stream_base::timeout::suggested(beast::role_type::client)); // Set a decorator to change the User-Agent of the handshake - tcp_ws_.set_option(websocket::stream_base::decorator([](websocket::request_type& req) { req.set(http::field::user_agent, WS_CLIENT_NAME); })); + getWs().set_option(websocket::stream_base::decorator([](websocket::request_type& req) { req.set(http::field::user_agent, WS_CLIENT_NAME); })); // Perform the websocket handshake - tcp_ws_.handshake(server_.host + ":" + std::to_string(server_.port), "/stream", ec); + getWs().handshake(server_.host + ":" + std::to_string(server_.port), "/stream", ec); return ec; } void ClientConnectionWs::write(boost::asio::streambuf& buffer, WriteHandler&& write_handler) { - tcp_ws_.async_write(boost::asio::buffer(buffer.data()), write_handler); + getWs().async_write(boost::asio::buffer(buffer.data()), write_handler); } @@ -495,12 +516,23 @@ void ClientConnectionWs::write(boost::asio::streambuf& buffer, WriteHandler&& wr ClientConnectionWss::ClientConnectionWss(boost::asio::io_context& io_context, boost::asio::ssl::context& ssl_context, ClientSettings::Server server) - : ClientConnection(io_context, std::move(server)), ssl_ws_(strand_, ssl_context) + : ClientConnection(io_context, std::move(server)), ssl_context_(ssl_context) { - if (server.certificate.has_value()) + getWs(); +} + + +ssl_websocket& ClientConnectionWss::getWs() +{ + std::lock_guard lock(ws_mutex_); + if (ssl_ws_.has_value()) + return ssl_ws_.value(); + + ssl_ws_.emplace(strand_, ssl_context_); + if (server_.certificate.has_value()) { - ssl_ws_.next_layer().set_verify_mode(boost::asio::ssl::verify_peer); - ssl_ws_.next_layer().set_verify_callback([](bool preverified, boost::asio::ssl::verify_context& ctx) + ssl_ws_->next_layer().set_verify_mode(boost::asio::ssl::verify_peer); + ssl_ws_->next_layer().set_verify_callback([](bool preverified, boost::asio::ssl::verify_context& ctx) { // The verify callback can be used to check whether the certificate that is // being presented is valid for the peer. For example, RFC 2818 describes @@ -518,6 +550,7 @@ ClientConnectionWss::ClientConnectionWss(boost::asio::io_context& io_context, bo return preverified; }); } + return ssl_ws_.value(); } @@ -530,16 +563,19 @@ ClientConnectionWss::~ClientConnectionWss() void ClientConnectionWss::disconnect() { LOG(DEBUG, LOG_TAG) << "Disconnecting\n"; - if (!ssl_ws_.is_open()) - { - LOG(DEBUG, LOG_TAG) << "Not connected\n"; - return; - } boost::system::error_code ec; - ssl_ws_.close(websocket::close_code::normal, ec); - if (ec) - LOG(ERROR, LOG_TAG) << "Error in socket close: " << ec.message() << "\n"; + + if (getWs().is_open()) + getWs().close(websocket::close_code::normal, ec); + // if (ec) + // LOG(ERROR, LOG_TAG) << "Error in socket close: " << ec.message() << "\n"; + if (getWs().next_layer().lowest_layer().is_open()) + { + getWs().next_layer().lowest_layer().shutdown(boost::asio::ip::tcp::socket::shutdown_both, ec); + getWs().next_layer().lowest_layer().close(ec); + } boost::asio::post(strand_, [this]() { pendingRequests_.clear(); }); + ssl_ws_ = std::nullopt; LOG(DEBUG, LOG_TAG) << "Disconnected\n"; } @@ -548,23 +584,23 @@ std::string ClientConnectionWss::getMacAddress() { std::string mac = #ifndef WINDOWS - ::getMacAddress(ssl_ws_.next_layer().lowest_layer().native_handle()); + ::getMacAddress(getWs().next_layer().lowest_layer().native_handle()); #else ::getMacAddress(ssl_ws_.next_layer().lowest_layer().local_endpoint().address().to_string()); #endif if (mac.empty()) mac = "00:00:00:00:00:00"; - LOG(INFO, LOG_TAG) << "My MAC: \"" << mac << "\", socket: " << ssl_ws_.next_layer().lowest_layer().native_handle() << "\n"; + LOG(INFO, LOG_TAG) << "My MAC: \"" << mac << "\", socket: " << getWs().next_layer().lowest_layer().native_handle() << "\n"; return mac; } void ClientConnectionWss::getNextMessage(const MessageHandler& handler) { - ssl_ws_.async_read(buffer_, [this, handler](beast::error_code ec, std::size_t bytes_transferred) mutable + getWs().async_read(buffer_, [this, handler](beast::error_code ec, std::size_t bytes_transferred) mutable { tv now; - LOG(DEBUG, LOG_TAG) << "on_read_ws, ec: " << ec << ", bytes_transferred: " << bytes_transferred << "\n"; + LOG(TRACE, LOG_TAG) << "on_read_ws, ec: " << ec << ", bytes_transferred: " << bytes_transferred << "\n"; // This indicates that the session was closed if (ec == websocket::error::closed) @@ -593,7 +629,7 @@ void ClientConnectionWss::getNextMessage(const MessageHandler& if (!response) LOG(WARNING, LOG_TAG) << "Failed to deserialize message of type: " << base_message_.type << "\n"; else - LOG(DEBUG, LOG_TAG) << "getNextMessage: " << response->type << ", size: " << response->size << ", id: " << response->id + LOG(TRACE, LOG_TAG) << "getNextMessage: " << response->type << ", size: " << response->size << ", id: " << response->id << ", refers: " << response->refersTo << "\n"; messageReceived(std::move(response), handler); @@ -604,32 +640,40 @@ void ClientConnectionWss::getNextMessage(const MessageHandler& boost::system::error_code ClientConnectionWss::doConnect(boost::asio::ip::basic_endpoint endpoint) { boost::system::error_code ec; - ssl_ws_.binary(true); - beast::get_lowest_layer(ssl_ws_).connect(endpoint, ec); + getWs().binary(true); + beast::get_lowest_layer(*ssl_ws_).connect(endpoint, ec); + if (ec.failed()) + return ec; // Set a timeout on the operation // beast::get_lowest_layer(ssl_ws_).expires_after(std::chrono::seconds(30)); // Set suggested timeout settings for the websocket - ssl_ws_.set_option(websocket::stream_base::timeout::suggested(beast::role_type::client)); + getWs().set_option(websocket::stream_base::timeout::suggested(beast::role_type::client)); // Set SNI Hostname (many hosts need this to handshake successfully) - if (!SSL_set_tlsext_host_name(ssl_ws_.next_layer().native_handle(), server_.host.c_str())) - throw beast::system_error(beast::error_code(static_cast(::ERR_get_error()), boost::asio::error::get_ssl_category()), "Failed to set SNI Hostname"); + if (!SSL_set_tlsext_host_name(getWs().next_layer().native_handle(), server_.host.c_str())) + { + LOG(ERROR, LOG_TAG) << "Failed to set SNI Hostname\n"; + return boost::system::error_code(static_cast(::ERR_get_error()), boost::asio::error::get_ssl_category()); + } // Perform the SSL handshake - ssl_ws_.next_layer().handshake(boost::asio::ssl::stream_base::client); + getWs().next_layer().handshake(boost::asio::ssl::stream_base::client, ec); + if (ec.failed()) + return ec; // Set a decorator to change the User-Agent of the handshake - ssl_ws_.set_option(websocket::stream_base::decorator([](websocket::request_type& req) { req.set(http::field::user_agent, WS_CLIENT_NAME); })); + getWs().set_option(websocket::stream_base::decorator([](websocket::request_type& req) { req.set(http::field::user_agent, WS_CLIENT_NAME); })); // Perform the websocket handshake - ssl_ws_.handshake(server_.host + ":" + std::to_string(server_.port), "/stream", ec); + getWs().handshake(server_.host + ":" + std::to_string(server_.port), "/stream", ec); + return ec; } void ClientConnectionWss::write(boost::asio::streambuf& buffer, WriteHandler&& write_handler) { - ssl_ws_.async_write(boost::asio::buffer(buffer.data()), write_handler); + getWs().async_write(boost::asio::buffer(buffer.data()), write_handler); } diff --git a/client/client_connection.hpp b/client/client_connection.hpp index ef38777f..94393937 100644 --- a/client/client_connection.hpp +++ b/client/client_connection.hpp @@ -37,6 +37,8 @@ // standard headers #include #include +#include +#include #include @@ -235,10 +237,15 @@ private: boost::system::error_code doConnect(boost::asio::ip::basic_endpoint endpoint) override; void write(boost::asio::streambuf& buffer, WriteHandler&& write_handler) override; + /// @return the websocket + tcp_websocket& getWs(); + /// TCP web socket - tcp_websocket tcp_ws_; + std::optional tcp_ws_; /// Receive buffer boost::beast::flat_buffer buffer_; + /// protect ssl_ws_ + std::mutex ws_mutex_; }; @@ -260,8 +267,15 @@ private: boost::system::error_code doConnect(boost::asio::ip::basic_endpoint endpoint) override; void write(boost::asio::streambuf& buffer, WriteHandler&& write_handler) override; + /// @return the websocket + ssl_websocket& getWs(); + + /// SSL context + boost::asio::ssl::context& ssl_context_; /// SSL web socket - ssl_websocket ssl_ws_; + std::optional ssl_ws_; /// Receive buffer boost::beast::flat_buffer buffer_; + /// protect ssl_ws_ + std::mutex ws_mutex_; }; diff --git a/common/snap_exception.hpp b/common/snap_exception.hpp index b40fc97b..d0bdae03 100644 --- a/common/snap_exception.hpp +++ b/common/snap_exception.hpp @@ -1,6 +1,6 @@ /*** This file is part of snapcast - Copyright (C) 2014-2024 Johannes Pohl + Copyright (C) 2014-2025 Johannes Pohl This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by @@ -29,15 +29,15 @@ class SnapException : public std::exception int error_code_; public: - SnapException(const char* text, int error_code = 0) : text_(text), error_code_(error_code) + explicit SnapException(const char* text, int error_code = 0) : text_(text), error_code_(error_code) { } - SnapException(const std::string& text, int error_code = 0) : SnapException(text.c_str(), error_code) + explicit SnapException(const std::string& text, int error_code = 0) : SnapException(text.c_str(), error_code) { } - ~SnapException() throw() override = default; + ~SnapException() override = default; int code() const noexcept { From 29e267532aaef7d7291885422199db666fb8c917 Mon Sep 17 00:00:00 2001 From: badaix Date: Mon, 27 Jan 2025 10:52:28 +0100 Subject: [PATCH 14/37] Fix Windows compile error --- client/client_connection.cpp | 12 +++++++++--- client/controller.cpp | 4 ++-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/client/client_connection.cpp b/client/client_connection.cpp index e046f115..1a5d7118 100644 --- a/client/client_connection.cpp +++ b/client/client_connection.cpp @@ -399,6 +399,9 @@ ClientConnectionWs::~ClientConnectionWs() tcp_websocket& ClientConnectionWs::getWs() { + // Looks like the websocket must be recreated after disconnect: + // https://github.com/boostorg/beast/issues/2409#issuecomment-1103685782 + std::lock_guard lock(ws_mutex_); if (tcp_ws_.has_value()) return tcp_ws_.value(); @@ -524,6 +527,9 @@ ClientConnectionWss::ClientConnectionWss(boost::asio::io_context& io_context, bo ssl_websocket& ClientConnectionWss::getWs() { + // Looks like the websocket must be recreated after disconnect: + // https://github.com/boostorg/beast/issues/2409#issuecomment-1103685782 + std::lock_guard lock(ws_mutex_); if (ssl_ws_.has_value()) return ssl_ws_.value(); @@ -586,7 +592,7 @@ std::string ClientConnectionWss::getMacAddress() #ifndef WINDOWS ::getMacAddress(getWs().next_layer().lowest_layer().native_handle()); #else - ::getMacAddress(ssl_ws_.next_layer().lowest_layer().local_endpoint().address().to_string()); + ::getMacAddress(getWs().next_layer().lowest_layer().local_endpoint().address().to_string()); #endif if (mac.empty()) mac = "00:00:00:00:00:00"; @@ -641,12 +647,12 @@ boost::system::error_code ClientConnectionWss::doConnect(boost::asio::ip::basic_ { boost::system::error_code ec; getWs().binary(true); - beast::get_lowest_layer(*ssl_ws_).connect(endpoint, ec); + beast::get_lowest_layer(getWs()).connect(endpoint, ec); if (ec.failed()) return ec; // Set a timeout on the operation - // beast::get_lowest_layer(ssl_ws_).expires_after(std::chrono::seconds(30)); + // beast::get_lowest_layer(getWs()).expires_after(std::chrono::seconds(30)); // Set suggested timeout settings for the websocket getWs().set_option(websocket::stream_base::timeout::suggested(beast::role_type::client)); diff --git a/client/controller.cpp b/client/controller.cpp index b8d35a94..d6865ea9 100644 --- a/client/controller.cpp +++ b/client/controller.cpp @@ -88,9 +88,9 @@ Controller::Controller(boost::asio::io_context& io_context, const ClientSettings LOG(WARNING, LOG_TAG) << "Failed to load system certificates: " << ec << "\n"; if (!settings.server.certificate->empty()) { - ssl_context_.load_verify_file(settings.server.certificate.value(), ec); + ssl_context_.load_verify_file(settings.server.certificate.value().string(), ec); if (ec.failed()) - throw SnapException("Failed to load certificate: " + settings.server.certificate.value().native() + ": " + ec.message()); + throw SnapException("Failed to load certificate: " + settings.server.certificate.value().string() + ": " + ec.message()); } } } From be301c69319ad9fb7fec811ff49bf060dc6a0836 Mon Sep 17 00:00:00 2001 From: badaix Date: Mon, 27 Jan 2025 12:53:51 +0100 Subject: [PATCH 15/37] Log configured protocoll --- client/client_connection.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/client_connection.cpp b/client/client_connection.cpp index 1a5d7118..9f3f4025 100644 --- a/client/client_connection.cpp +++ b/client/client_connection.cpp @@ -129,7 +129,7 @@ void ClientConnection::connect(const ResultHandler& handler) for (const auto& iter : iterator) { - LOG(INFO, LOG_TAG) << "Connecting to " << iter.endpoint() << "\n"; + LOG(INFO, LOG_TAG) << "Connecting to " << server_.protocol << "://" << iter.endpoint() << "\n"; ec = doConnect(iter.endpoint()); if (!ec || (ec == boost::system::errc::interrupted)) { From 85e8d02e5b001e50a691d2729f193b0c9402ddfe Mon Sep 17 00:00:00 2001 From: badaix Date: Mon, 27 Jan 2025 22:19:42 +0100 Subject: [PATCH 16/37] Mutual SSL authentication --- client/client_connection.cpp | 4 ++-- client/client_settings.hpp | 8 ++++++- client/controller.cpp | 39 +++++++++++++++++++++++++------ client/snapclient.cpp | 31 +++++++++++++++++++++---- common/popl.hpp | 10 ++++---- server/control_server.cpp | 45 ++++++++++++++++++++++++++++++++++-- server/etc/snapserver.conf | 7 ++++++ server/server_settings.hpp | 40 +++++++++++++++++++++++++------- server/snapserver.cpp | 10 ++++++++ 9 files changed, 164 insertions(+), 30 deletions(-) diff --git a/client/client_connection.cpp b/client/client_connection.cpp index 9f3f4025..b8e39a61 100644 --- a/client/client_connection.cpp +++ b/client/client_connection.cpp @@ -535,7 +535,7 @@ ssl_websocket& ClientConnectionWss::getWs() return ssl_ws_.value(); ssl_ws_.emplace(strand_, ssl_context_); - if (server_.certificate.has_value()) + if (server_.server_certificate.has_value()) { ssl_ws_->next_layer().set_verify_mode(boost::asio::ssl::verify_peer); ssl_ws_->next_layer().set_verify_callback([](bool preverified, boost::asio::ssl::verify_context& ctx) @@ -551,7 +551,7 @@ ssl_websocket& ClientConnectionWss::getWs() char subject_name[256]; X509* cert = X509_STORE_CTX_get_current_cert(ctx.native_handle()); X509_NAME_oneline(X509_get_subject_name(cert), subject_name, 256); - LOG(INFO, LOG_TAG) << "verifying cert: '" << subject_name << "', pre verified: " << preverified << "\n"; + LOG(INFO, LOG_TAG) << "Verifying cert: '" << subject_name << "', pre verified: " << preverified << "\n"; return preverified; }); diff --git a/client/client_settings.hpp b/client/client_settings.hpp index 7d63448a..0e671c00 100644 --- a/client/client_settings.hpp +++ b/client/client_settings.hpp @@ -67,7 +67,13 @@ struct ClientSettings /// server port size_t port{1704}; /// server certificate - std::optional certificate; + std::optional server_certificate; + /// Certificate file + std::filesystem::path certificate; + /// Private key file + std::filesystem::path certificate_key; + /// Password for encrypted key file + std::string key_password; /// Is ssl in use? bool isSsl() const { diff --git a/client/controller.cpp b/client/controller.cpp index d6865ea9..dc81462d 100644 --- a/client/controller.cpp +++ b/client/controller.cpp @@ -80,17 +80,42 @@ Controller::Controller(boost::asio::io_context& io_context, const ClientSettings : io_context_(io_context), ssl_context_(boost::asio::ssl::context::tlsv12_client), timer_(io_context), settings_(settings), stream_(nullptr), decoder_(nullptr), player_(nullptr), serverSettings_(nullptr) { - if (settings.server.isSsl() && settings.server.certificate.has_value()) + if (settings.server.isSsl()) { boost::system::error_code ec; - ssl_context_.set_default_verify_paths(ec); - if (ec.failed()) - LOG(WARNING, LOG_TAG) << "Failed to load system certificates: " << ec << "\n"; - if (!settings.server.certificate->empty()) + if (settings.server.server_certificate.has_value()) { - ssl_context_.load_verify_file(settings.server.certificate.value().string(), ec); + LOG(DEBUG, LOG_TAG) << "Loading server certificate\n"; + ssl_context_.set_default_verify_paths(ec); if (ec.failed()) - throw SnapException("Failed to load certificate: " + settings.server.certificate.value().string() + ": " + ec.message()); + LOG(WARNING, LOG_TAG) << "Failed to load system certificates: " << ec << "\n"; + if (!settings.server.server_certificate->empty()) + { + ssl_context_.load_verify_file(settings.server.server_certificate.value().string(), ec); + if (ec.failed()) + throw SnapException("Failed to load server certificate: " + settings.server.server_certificate.value().string() + ": " + ec.message()); + } + } + + if (!settings.server.certificate.empty() && !settings.server.certificate_key.empty()) + { + if (!settings.server.key_password.empty()) + { + ssl_context_.set_password_callback( + [pw = settings.server.key_password](size_t max_length, boost::asio::ssl::context_base::password_purpose purpose) -> string + { + LOG(DEBUG, LOG_TAG) << "getPassword, purpose: " << purpose << ", max length: " << max_length << "\n"; + return pw; + }); + } + LOG(DEBUG, LOG_TAG) << "Loading certificate file: " << settings.server.certificate << "\n"; + ssl_context_.use_certificate_chain_file(settings.server.certificate.string(), ec); + if (ec.failed()) + throw SnapException("Failed to load certificate: " + settings.server.certificate.string() + ": " + ec.message()); + LOG(DEBUG, LOG_TAG) << "Loading certificate key file: " << settings.server.certificate_key << "\n"; + ssl_context_.use_private_key_file(settings.server.certificate_key.string(), boost::asio::ssl::context::pem, ec); + if (ec.failed()) + throw SnapException("Failed to load private key file: " + settings.server.certificate_key.string() + ": " + ec.message()); } } } diff --git a/client/snapclient.cpp b/client/snapclient.cpp index 281c3f5f..41ca2cf5 100644 --- a/client/snapclient.cpp +++ b/client/snapclient.cpp @@ -147,7 +147,12 @@ int main(int argc, char** argv) auto port_opt = op.add>("p", "port", "(deprecated, use [url]) Server port", 1704, &settings.server.port); op.add>("i", "instance", "Instance id when running multiple instances on the same host", 1, &settings.instance); op.add>("", "hostID", "Unique host id, default is MAC address", "", &settings.host_id); - auto server_cert_opt = op.add>("", "server-cert", "Verify server with certificate", "default certificates"); + auto server_cert_opt = + op.add>("", "server-cert", "Verify server with certificate (PEM format)", "default certificates"); + op.add>("", "cert", "Client certificate file (PEM format)", settings.server.certificate, &settings.server.certificate); + op.add>("", "cert-key", "Client private key file (PEM format)", settings.server.certificate_key, + &settings.server.certificate_key); + op.add>("", "key-password", "Key password (for encrypted private key)", settings.server.key_password, &settings.server.key_password); // PCM device specific #if defined(HAS_ALSA) || defined(HAS_PULSE) || defined(HAS_WASAPI) @@ -349,13 +354,28 @@ int main(int argc, char** argv) if (server_cert_opt->is_set()) { if (server_cert_opt->get_default() == server_cert_opt->value()) - settings.server.certificate = ""; + settings.server.server_certificate = ""; else - settings.server.certificate = std::filesystem::weakly_canonical(server_cert_opt->value()); - if (settings.server.certificate.value_or("").empty()) + settings.server.server_certificate = std::filesystem::weakly_canonical(server_cert_opt->value()); + if (settings.server.server_certificate.value_or("").empty()) LOG(INFO, LOG_TAG) << "Server certificate: default certificates\n"; else - LOG(INFO, LOG_TAG) << "Server certificate: " << settings.server.certificate.value_or("") << "\n"; + LOG(INFO, LOG_TAG) << "Server certificate: " << settings.server.server_certificate.value_or("") << "\n"; + } + + if (!settings.server.certificate.empty() && !settings.server.certificate_key.empty()) + { + namespace fs = std::filesystem; + settings.server.certificate = fs::weakly_canonical(settings.server.certificate); + if (!fs::exists(settings.server.certificate)) + throw SnapException("Certificate file not found: " + settings.server.certificate.native()); + settings.server.certificate_key = fs::weakly_canonical(settings.server.certificate_key); + if (!fs::exists(settings.server.certificate_key)) + throw SnapException("Certificate_key file not found: " + settings.server.certificate_key.native()); + } + else if (settings.server.certificate.empty() != settings.server.certificate_key.empty()) + { + throw SnapException("Both SSL 'certificate' and 'certificate_key' must be set or empty"); } #if !defined(HAS_AVAHI) && !defined(HAS_BONJOUR) @@ -500,6 +520,7 @@ int main(int argc, char** argv) int num_threads = 0; std::vector threads; + threads.reserve(num_threads); for (int n = 0; n < num_threads; ++n) threads.emplace_back([&] { io_context.run(); }); io_context.run(); diff --git a/common/popl.hpp b/common/popl.hpp index 6ee354fe..a59b7073 100644 --- a/common/popl.hpp +++ b/common/popl.hpp @@ -483,7 +483,7 @@ public: std::string print(const Attribute& max_attribute = Attribute::optional) const override; private: - std::string to_string(Option_ptr option) const; + std::string to_string(const Option_ptr& option) const; }; @@ -501,7 +501,7 @@ public: std::string print(const Attribute& max_attribute = Attribute::optional) const override; private: - std::string to_string(Option_ptr option) const; + std::string to_string(const Option_ptr& option) const; }; @@ -1122,7 +1122,7 @@ inline ConsoleOptionPrinter::ConsoleOptionPrinter(const OptionParser* option_par } -inline std::string ConsoleOptionPrinter::to_string(Option_ptr option) const +inline std::string ConsoleOptionPrinter::to_string(const Option_ptr& option) const { std::stringstream line; if (option->short_name() != 0) @@ -1142,7 +1142,7 @@ inline std::string ConsoleOptionPrinter::to_string(Option_ptr option) const std::stringstream defaultStr; if (option->get_default(defaultStr)) { - if (!defaultStr.str().empty()) + if (!defaultStr.str().empty() && (defaultStr.str() != "\"\"")) line << " (=" << defaultStr.str() << ")"; } } @@ -1216,7 +1216,7 @@ inline GroffOptionPrinter::GroffOptionPrinter(const OptionParser* option_parser) } -inline std::string GroffOptionPrinter::to_string(Option_ptr option) const +inline std::string GroffOptionPrinter::to_string(const Option_ptr& option) const { std::stringstream line; if (option->short_name() != 0) diff --git a/server/control_server.cpp b/server/control_server.cpp index fa74df41..9950c67b 100644 --- a/server/control_server.cpp +++ b/server/control_server.cpp @@ -22,6 +22,7 @@ // local headers #include "common/aixlog.hpp" #include "common/json.hpp" +#include "common/snap_exception.hpp" #include "control_session_http.hpp" #include "control_session_tcp.hpp" #include "server_settings.hpp" @@ -54,10 +55,50 @@ ControlServer::ControlServer(boost::asio::io_context& io_context, const ServerSe return pw; }); } + if (!ssl.certificate.empty() && !ssl.certificate_key.empty()) { - ssl_context_.use_certificate_chain_file(ssl.certificate); - ssl_context_.use_private_key_file(ssl.certificate_key, boost::asio::ssl::context::pem); + boost::system::error_code ec; + ssl_context_.use_certificate_chain_file(ssl.certificate, ec); + if (ec.failed()) + throw SnapException("Failed to load certificate: " + settings.ssl.certificate.string() + ": " + ec.message()); + ssl_context_.use_private_key_file(ssl.certificate_key, boost::asio::ssl::context::pem, ec); + if (ec.failed()) + throw SnapException("Failed to load private key file: " + settings.ssl.certificate_key.string() + ": " + ec.message()); + } + + if (settings.ssl.verify_clients) + { + boost::system::error_code ec; + ssl_context_.set_default_verify_paths(ec); + if (ec.failed()) + LOG(WARNING, LOG_TAG) << "Failed to load system certificates: " << ec << "\n"; + for (const auto& cert_path : settings_.ssl.client_certs) + { + LOG(DEBUG, LOG_TAG) << "Loading client certificate: " << cert_path << "\n"; + ssl_context_.load_verify_file(cert_path.string(), ec); + if (ec.failed()) + throw SnapException("Failed to load client certificate: " + cert_path.string() + ": " + ec.message()); + } + + ssl_context_.set_verify_mode(boost::asio::ssl::verify_peer | boost::asio::ssl::verify_fail_if_no_peer_cert); + ssl_context_.set_verify_callback([](bool preverified, boost::asio::ssl::verify_context& ctx) + { + // The verify callback can be used to check whether the certificate that is + // being presented is valid for the peer. For example, RFC 2818 describes + // the steps involved in doing this for HTTPS. Consult the OpenSSL + // documentation for more details. Note that the callback is called once + // for each certificate in the certificate chain, starting from the root + // certificate authority. + + // In this example we will simply print the certificate's subject name. + char subject_name[256]; + X509* cert = X509_STORE_CTX_get_current_cert(ctx.native_handle()); + X509_NAME_oneline(X509_get_subject_name(cert), subject_name, 256); + LOG(INFO, LOG_TAG) << "Verifying cert: '" << subject_name << "', pre verified: " << preverified << "\n"; + + return preverified; + }); } // ssl_context_.use_tmp_dh_file("dh4096.pem"); } diff --git a/server/etc/snapserver.conf b/server/etc/snapserver.conf index aaa7f53a..0c0d275b 100644 --- a/server/etc/snapserver.conf +++ b/server/etc/snapserver.conf @@ -62,6 +62,13 @@ # Password for decryption of the certificate_key (only needed for encrypted certificate_key file) #key_password = +# Verify client certificates +#verify_clients = false + +# List of client CA certificate files, can be configured multiple times +#client_cert = +#client_cert = + # ############################################################################### diff --git a/server/server_settings.hpp b/server/server_settings.hpp index d3935227..fd11025f 100644 --- a/server/server_settings.hpp +++ b/server/server_settings.hpp @@ -31,27 +31,43 @@ struct ServerSettings { + /// Launch settings struct Server { + /// Number of worker threads int threads{-1}; + /// PID file, if running as daemon std::string pid_file{"/var/run/snapserver/pid"}; + /// User when running as deaemon std::string user{"snapserver"}; + /// Group when running as deaemon std::string group; + /// Server data dir std::string data_dir; }; + /// SSL settings struct Ssl { + /// Certificate file std::filesystem::path certificate; + /// Private key file std::filesystem::path certificate_key; + /// Password for encrypted key file std::string key_password; + /// Verify client certificates + bool verify_clients = false; + /// Client CA certificates + std::vector client_certs; + /// @return if SSL is enabled bool enabled() const { return !certificate.empty() && !certificate_key.empty(); } }; + /// User settings struct User { explicit User(const std::string& user_permissions_password) @@ -67,8 +83,8 @@ struct ServerSettings std::string password; }; - std::vector users; + /// HTTP settings struct Http { bool enabled{true}; @@ -82,6 +98,7 @@ struct ServerSettings std::string url_prefix; }; + /// TCP streaming client settings struct Tcp { bool enabled{true}; @@ -89,6 +106,7 @@ struct ServerSettings std::vector bind_to_address{{"::"}}; }; + /// Stream settings struct Stream { size_t port{1704}; @@ -102,22 +120,28 @@ struct ServerSettings std::vector bind_to_address{{"::"}}; }; + /// Client settings struct StreamingClient { + /// Initial volume of new clients uint16_t initialVolume{100}; }; + /// Logging settings struct Logging { + /// log sing std::string sink; + /// log filter std::string filter{"*:info"}; }; - Server server; - Ssl ssl; - Http http; - Tcp tcp; - Stream stream; - StreamingClient streamingclient; - Logging logging; + Server server; ///< Server settings + Ssl ssl; ///< SSL settings + std::vector users; ///< User settings + Http http; ///< HTTP settings + Tcp tcp; ///< TCP settings + Stream stream; ///< Stream settings + StreamingClient streamingclient; ///< Client settings + Logging logging; ///< Logging settings }; diff --git a/server/snapserver.cpp b/server/snapserver.cpp index 2b68c697..f2aa3825 100644 --- a/server/snapserver.cpp +++ b/server/snapserver.cpp @@ -86,6 +86,9 @@ int main(int argc, char* argv[]) conf.add>("", "ssl.certificate_key", "private key file (PEM format)", settings.ssl.certificate_key, &settings.ssl.certificate_key); conf.add>("", "ssl.key_password", "key password (for encrypted private key)", settings.ssl.key_password, &settings.ssl.key_password); + conf.add>("", "ssl.verify_clients", "Verify client certificates", settings.ssl.verify_clients, &settings.ssl.verify_clients); + auto client_cert_opt = + conf.add>("", "ssl.client_cert", "List of client CA certificate files, can be configured multiple times", ""); #if 0 // feature: users // Users setting @@ -276,6 +279,13 @@ int main(int argc, char* argv[]) settings.ssl.certificate_key = make_absolute(settings.ssl.certificate_key); if (!fs::exists(settings.ssl.certificate_key)) throw SnapException("SSL certificate_key file not found: " + settings.ssl.certificate_key.native()); + for (size_t n = 0; n < client_cert_opt->count(); ++n) + { + auto cert_file = std::filesystem::weakly_canonical(client_cert_opt->value(n)); + if (!fs::exists(cert_file)) + throw SnapException("Client certificate file not found: " + cert_file.string()); + settings.ssl.client_certs.push_back(std::move(cert_file)); + } } else if (settings.ssl.certificate.empty() != settings.ssl.certificate_key.empty()) { From c105fecc5bc93d8580004e1c1459e629a6d644da Mon Sep 17 00:00:00 2001 From: badaix Date: Mon, 27 Jan 2025 22:49:59 +0100 Subject: [PATCH 17/37] Fix Windows compile error --- client/snapclient.cpp | 4 ++-- server/server_settings.hpp | 27 ++++++++++++++++++++++++++- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/client/snapclient.cpp b/client/snapclient.cpp index 41ca2cf5..a8138da2 100644 --- a/client/snapclient.cpp +++ b/client/snapclient.cpp @@ -368,10 +368,10 @@ int main(int argc, char** argv) namespace fs = std::filesystem; settings.server.certificate = fs::weakly_canonical(settings.server.certificate); if (!fs::exists(settings.server.certificate)) - throw SnapException("Certificate file not found: " + settings.server.certificate.native()); + throw SnapException("Certificate file not found: " + settings.server.certificate.string()); settings.server.certificate_key = fs::weakly_canonical(settings.server.certificate_key); if (!fs::exists(settings.server.certificate_key)) - throw SnapException("Certificate_key file not found: " + settings.server.certificate_key.native()); + throw SnapException("Certificate_key file not found: " + settings.server.certificate_key.string()); } else if (settings.server.certificate.empty() != settings.server.certificate_key.empty()) { diff --git a/server/server_settings.hpp b/server/server_settings.hpp index fd11025f..b660124d 100644 --- a/server/server_settings.hpp +++ b/server/server_settings.hpp @@ -28,7 +28,7 @@ #include #include - +/// Server settings struct ServerSettings { /// Launch settings @@ -70,6 +70,7 @@ struct ServerSettings /// User settings struct User { + /// c'tor explicit User(const std::string& user_permissions_password) { std::string perm; @@ -78,8 +79,11 @@ struct ServerSettings permissions = utils::string::split(perm, ','); } + /// user name std::string name; + /// permissions std::vector permissions; + /// password std::string password; }; @@ -87,36 +91,57 @@ struct ServerSettings /// HTTP settings struct Http { + /// enable HTTP server bool enabled{true}; + /// enable HTTPS bool ssl_enabled{false}; + /// HTTP port size_t port{1780}; + /// HTTPS port size_t ssl_port{1788}; + /// HTTP listen address std::vector bind_to_address{{"::"}}; + /// HTTPS listen address std::vector ssl_bind_to_address{{"::"}}; + /// doc root directory std::string doc_root; + /// HTTP server host name std::string host{""}; + /// URL prefix when serving album art std::string url_prefix; }; /// TCP streaming client settings struct Tcp { + /// enable plain TCP audio streaming bool enabled{true}; + /// TCP port size_t port{1705}; + /// TCP listen addresses std::vector bind_to_address{{"::"}}; }; /// Stream settings struct Stream { + /// Audio streaming port size_t port{1704}; + /// Directory for stream plugins std::filesystem::path plugin_dir{"/usr/share/snapserver/plug-ins"}; + /// Stream sources std::vector sources; + /// Default codec std::string codec{"flac"}; + /// Default end to end delay int32_t bufferMs{1000}; + /// Default sample format std::string sampleFormat{"48000:16:2"}; + /// Default read size for stream sources size_t streamChunkMs{20}; + /// Send audio to muted clients? bool sendAudioToMutedClients{false}; + /// Liste addresses std::vector bind_to_address{{"::"}}; }; From 2addf7cc3dfa27444b79e8805a56849ec03c4f21 Mon Sep 17 00:00:00 2001 From: badaix Date: Tue, 28 Jan 2025 17:57:36 +0100 Subject: [PATCH 18/37] Build without wss support, if OpenSSL is not found --- CMakeLists.txt | 2 -- client/CMakeLists.txt | 8 +++++++- client/client_connection.cpp | 3 +++ client/client_connection.hpp | 8 ++++++-- client/controller.cpp | 11 +++++++++-- client/controller.hpp | 2 ++ client/snapclient.cpp | 23 +++++++++++++++++++++-- common/CMakeLists.txt | 4 +--- common/stream_uri.cpp | 3 +-- server/CMakeLists.txt | 3 +++ server/authinfo.cpp | 4 ++-- {common => server}/jwt.cpp | 2 +- {common => server}/jwt.hpp | 2 +- server/server_settings.hpp | 14 +++++++------- test/CMakeLists.txt | 4 +++- test/test_main.cpp | 2 +- 16 files changed, 68 insertions(+), 27 deletions(-) rename {common => server}/jwt.cpp (99%) rename {common => server}/jwt.hpp (99%) diff --git a/CMakeLists.txt b/CMakeLists.txt index 57380fb3..b680c360 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -177,8 +177,6 @@ endif() find_package(Threads REQUIRED) -find_package(OpenSSL REQUIRED) - include(CMakePushCheckState) include(CheckIncludeFileCXX) include_directories(${INCLUDE_DIRS}) diff --git a/client/CMakeLists.txt b/client/CMakeLists.txt index 4f6cf54e..a3a44766 100644 --- a/client/CMakeLists.txt +++ b/client/CMakeLists.txt @@ -62,7 +62,13 @@ elseif(NOT ANDROID) endif(PULSE_FOUND) endif(MACOSX) -list(APPEND CLIENT_LIBRARIES OpenSSL::Crypto OpenSSL::SSL) +find_package(OpenSSL) +if(OpenSSL_FOUND) + add_compile_definitions(HAS_OPENSSL) + list(APPEND CLIENT_LIBRARIES OpenSSL::Crypto OpenSSL::SSL) +else() + message(STATUS "OpenSSL not found, building without wss support") +endif() if(ANDROID) list(APPEND CLIENT_LIBRARIES oboe::oboe) diff --git a/client/client_connection.cpp b/client/client_connection.cpp index b8e39a61..73c06d14 100644 --- a/client/client_connection.cpp +++ b/client/client_connection.cpp @@ -517,6 +517,7 @@ void ClientConnectionWs::write(boost::asio::streambuf& buffer, WriteHandler&& wr /////////////////////////////// SSL Websockets //////////////////////////////// +#ifdef HAS_OPENSSL ClientConnectionWss::ClientConnectionWss(boost::asio::io_context& io_context, boost::asio::ssl::context& ssl_context, ClientSettings::Server server) : ClientConnection(io_context, std::move(server)), ssl_context_(ssl_context) @@ -683,3 +684,5 @@ void ClientConnectionWss::write(boost::asio::streambuf& buffer, WriteHandler&& w { getWs().async_write(boost::asio::buffer(buffer.data()), write_handler); } + +#endif // HAS_OPENSSL diff --git a/client/client_connection.hpp b/client/client_connection.hpp index 94393937..4e603f75 100644 --- a/client/client_connection.hpp +++ b/client/client_connection.hpp @@ -48,8 +48,9 @@ namespace websocket = beast::websocket; // from using tcp_socket = boost::asio::ip::tcp::socket; using ssl_socket = boost::asio::ssl::stream; using tcp_websocket = websocket::stream; +#ifdef HAS_OPENSSL using ssl_websocket = websocket::stream; - +#endif class ClientConnection; @@ -244,11 +245,12 @@ private: std::optional tcp_ws_; /// Receive buffer boost::beast::flat_buffer buffer_; - /// protect ssl_ws_ + /// protect tcp_ws_ std::mutex ws_mutex_; }; +#ifdef HAS_OPENSSL /// Websocket connection class ClientConnectionWss : public ClientConnection @@ -279,3 +281,5 @@ private: /// protect ssl_ws_ std::mutex ws_mutex_; }; + +#endif // HAS_OPENSSL diff --git a/client/controller.cpp b/client/controller.cpp index dc81462d..ca7b0acc 100644 --- a/client/controller.cpp +++ b/client/controller.cpp @@ -77,9 +77,13 @@ static constexpr auto LOG_TAG = "Controller"; static constexpr auto TIME_SYNC_INTERVAL = 1s; Controller::Controller(boost::asio::io_context& io_context, const ClientSettings& settings) - : io_context_(io_context), ssl_context_(boost::asio::ssl::context::tlsv12_client), timer_(io_context), settings_(settings), stream_(nullptr), - decoder_(nullptr), player_(nullptr), serverSettings_(nullptr) + : io_context_(io_context), +#ifdef HAS_OPENSSL + ssl_context_(boost::asio::ssl::context::tlsv12_client), +#endif + timer_(io_context), settings_(settings), stream_(nullptr), decoder_(nullptr), player_(nullptr), serverSettings_(nullptr) { +#ifdef HAS_OPENSSL if (settings.server.isSsl()) { boost::system::error_code ec; @@ -118,6 +122,7 @@ Controller::Controller(boost::asio::io_context& io_context, const ClientSettings throw SnapException("Failed to load private key file: " + settings.server.certificate_key.string() + ": " + ec.message()); } } +#endif // HAS_OPENSSL } @@ -401,8 +406,10 @@ void Controller::start() { if (settings_.server.protocol == "ws") clientConnection_ = make_unique(io_context_, settings_.server); +#ifdef HAS_OPENSSL else if (settings_.server.protocol == "wss") clientConnection_ = make_unique(io_context_, ssl_context_, settings_.server); +#endif else clientConnection_ = make_unique(io_context_, settings_.server); worker(); diff --git a/client/controller.hpp b/client/controller.hpp index 48f9b157..59327730 100644 --- a/client/controller.hpp +++ b/client/controller.hpp @@ -64,7 +64,9 @@ private: void sendTimeSyncMessage(int quick_syncs); boost::asio::io_context& io_context_; +#ifdef HAS_OPENSSL boost::asio::ssl::context ssl_context_; +#endif boost::asio::steady_timer timer_; ClientSettings settings_; SampleFormat sampleFormat_; diff --git a/client/snapclient.cpp b/client/snapclient.cpp index a8138da2..0d5657c6 100644 --- a/client/snapclient.cpp +++ b/client/snapclient.cpp @@ -137,8 +137,14 @@ int main(int argc, char** argv) string pcm_device(player::DEFAULT_DEVICE); OptionParser op("Usage: snapclient [options...] [url]\n\n" - " With 'url' = ://[:port]\n" - " For example: \"tcp:\\\\192.168.1.1:1704\", or \"wss:\\\\homeserver.local\"\n" + " With 'url' = " +#ifdef HAS_OPENSSL + "" +#else + "" +#endif + "://[:port]\n" + " For example: \"tcp:\\\\192.168.1.1:1704\", or \"ws:\\\\homeserver.local\"\n" " If 'url' is not configured, snapclient tries to resolve the snapserver IP via mDNS\n"); auto helpSwitch = op.add("", "help", "Produce help message"); auto groffSwitch = op.add("", "groff", "Produce groff message"); @@ -335,10 +341,18 @@ int main(int argc, char** argv) } catch (...) { +#ifdef HAS_OPENSSL throw SnapException("Invalid URI - expected format: \"://[:port]\", with 'scheme' on of 'tcp', 'ws' or 'wss'"); +#else + throw SnapException("Invalid URI - expected format: \"://[:port]\", with 'scheme' on of 'tcp' or 'ws'"); +#endif } if ((uri.scheme != "tcp") && (uri.scheme != "ws") && (uri.scheme != "wss")) +#ifdef HAS_OPENSSL throw SnapException("Protocol must be one of 'tcp', 'ws' or 'wss'"); +#else + throw SnapException("Protocol must be one of 'tcp' or 'ws'"); +#endif settings.server.host = uri.host; settings.server.protocol = uri.scheme; if (uri.port.has_value()) @@ -348,7 +362,12 @@ int main(int argc, char** argv) else if (settings.server.protocol == "ws") settings.server.port = 1780; else if (settings.server.protocol == "wss") + { settings.server.port = 1788; +#ifndef HAS_OPENSSL + throw SnapException("Snapclient is built without wss support"); +#endif + } } if (server_cert_opt->is_set()) diff --git a/common/CMakeLists.txt b/common/CMakeLists.txt index be24a5ec..d8c18232 100644 --- a/common/CMakeLists.txt +++ b/common/CMakeLists.txt @@ -1,4 +1,4 @@ -set(SOURCES resampler.cpp sample_format.cpp jwt.cpp base64.cpp stream_uri.cpp +set(SOURCES resampler.cpp sample_format.cpp base64.cpp stream_uri.cpp utils/string_utils.cpp) if(NOT WIN32 AND NOT ANDROID) @@ -18,5 +18,3 @@ if(ANDROID) elseif(SOXR_FOUND) target_link_libraries(common ${SOXR_LIBRARIES}) endif() - -target_link_libraries(common OpenSSL::Crypto OpenSSL::SSL) diff --git a/common/stream_uri.cpp b/common/stream_uri.cpp index 350abf25..a431bdb5 100644 --- a/common/stream_uri.cpp +++ b/common/stream_uri.cpp @@ -18,7 +18,6 @@ #ifndef NOMINMAX #define NOMINMAX -#include #endif // NOMINMAX // prototype/interface header file @@ -88,7 +87,7 @@ void StreamUri::parse(const std::string& stream_uri) host = strutils::uriDecode(strutils::trim_copy(tmp.substr(0, pos))); std::string str_port; host = utils::string::split_left(host, ':', str_port); - port = std::atoi(str_port.c_str()); + port = std::strtol(str_port.c_str(), nullptr, 10); if (port == 0) port = std::nullopt; diff --git a/server/CMakeLists.txt b/server/CMakeLists.txt index 265825a0..be453da5 100644 --- a/server/CMakeLists.txt +++ b/server/CMakeLists.txt @@ -6,6 +6,7 @@ set(SERVER_SOURCES control_session_tcp.cpp control_session_http.cpp control_session_ws.cpp + jwt.cpp snapserver.cpp server.cpp stream_server.cpp @@ -36,6 +37,8 @@ include_directories(${CMAKE_SOURCE_DIR} ${CMAKE_SOURCE_DIR}/server) include_directories(SYSTEM ${Boost_INCLUDE_DIR}) +find_package(OpenSSL REQUIRED) + if(ANDROID) find_package(vorbis REQUIRED CONFIG) list(APPEND SERVER_LIBRARIES boost::boost) diff --git a/server/authinfo.cpp b/server/authinfo.cpp index c743bb8b..91f15bda 100644 --- a/server/authinfo.cpp +++ b/server/authinfo.cpp @@ -1,6 +1,6 @@ /*** This file is part of snapcast - Copyright (C) 2014-2024 Johannes Pohl + Copyright (C) 2014-2025 Johannes Pohl This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by @@ -22,8 +22,8 @@ // local headers #include "common/aixlog.hpp" #include "common/base64.h" -#include "common/jwt.hpp" #include "common/utils/string_utils.hpp" +#include "jwt.hpp" // 3rd party headers diff --git a/common/jwt.cpp b/server/jwt.cpp similarity index 99% rename from common/jwt.cpp rename to server/jwt.cpp index 4bec892b..fe5481d1 100644 --- a/common/jwt.cpp +++ b/server/jwt.cpp @@ -1,6 +1,6 @@ /*** This file is part of snapcast - Copyright (C) 2014-2024 Johannes Pohl + Copyright (C) 2014-2025 Johannes Pohl This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by diff --git a/common/jwt.hpp b/server/jwt.hpp similarity index 99% rename from common/jwt.hpp rename to server/jwt.hpp index bdcfc2a6..17aae26a 100644 --- a/common/jwt.hpp +++ b/server/jwt.hpp @@ -1,6 +1,6 @@ /*** This file is part of snapcast - Copyright (C) 2014-2024 Johannes Pohl + Copyright (C) 2014-2025 Johannes Pohl This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by diff --git a/server/server_settings.hpp b/server/server_settings.hpp index b660124d..97e27226 100644 --- a/server/server_settings.hpp +++ b/server/server_settings.hpp @@ -161,12 +161,12 @@ struct ServerSettings std::string filter{"*:info"}; }; - Server server; ///< Server settings - Ssl ssl; ///< SSL settings - std::vector users; ///< User settings - Http http; ///< HTTP settings - Tcp tcp; ///< TCP settings - Stream stream; ///< Stream settings + Server server; ///< Server settings + Ssl ssl; ///< SSL settings + std::vector users; ///< User settings + Http http; ///< HTTP settings + Tcp tcp; ///< TCP settings + Stream stream; ///< Stream settings StreamingClient streamingclient; ///< Client settings - Logging logging; ///< Logging settings + Logging logging; ///< Logging settings }; diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 7f21c00a..af9037f8 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -16,15 +16,17 @@ endif() # Make test executable set(TEST_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/test_main.cpp - ${CMAKE_SOURCE_DIR}/common/jwt.cpp ${CMAKE_SOURCE_DIR}/common/stream_uri.cpp ${CMAKE_SOURCE_DIR}/common/base64.cpp ${CMAKE_SOURCE_DIR}/common/utils/string_utils.cpp ${CMAKE_SOURCE_DIR}/server/authinfo.cpp + ${CMAKE_SOURCE_DIR}/server/jwt.cpp ${CMAKE_SOURCE_DIR}/server/streamreader/control_error.cpp ${CMAKE_SOURCE_DIR}/server/streamreader/properties.cpp ${CMAKE_SOURCE_DIR}/server/streamreader/metadata.cpp) +find_package(OpenSSL REQUIRED) + include_directories(SYSTEM ${Boost_INCLUDE_DIR}) add_executable(snapcast_test ${TEST_SOURCES}) diff --git a/test/test_main.cpp b/test/test_main.cpp index 8af0a863..bce2ab0e 100644 --- a/test/test_main.cpp +++ b/test/test_main.cpp @@ -22,10 +22,10 @@ #include "common/aixlog.hpp" #include "common/base64.h" #include "common/error_code.hpp" -#include "common/jwt.hpp" #include "common/stream_uri.hpp" #include "common/utils/string_utils.hpp" #include "server/authinfo.hpp" +#include "server/jwt.hpp" #include "server/server_settings.hpp" #include "server/streamreader/control_error.hpp" #include "server/streamreader/properties.hpp" From 5a535fade81e777b7bcce98fa6a6b369897f723a Mon Sep 17 00:00:00 2001 From: badaix Date: Tue, 28 Jan 2025 20:41:02 +0100 Subject: [PATCH 19/37] Fix building client without OpenSSL --- client/CMakeLists.txt | 2 +- client/client_connection.hpp | 4 +++- common/CMakeLists.txt | 2 -- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/client/CMakeLists.txt b/client/CMakeLists.txt index a3a44766..9dd29b06 100644 --- a/client/CMakeLists.txt +++ b/client/CMakeLists.txt @@ -67,7 +67,7 @@ if(OpenSSL_FOUND) add_compile_definitions(HAS_OPENSSL) list(APPEND CLIENT_LIBRARIES OpenSSL::Crypto OpenSSL::SSL) else() - message(STATUS "OpenSSL not found, building without wss support") + message(NOTICE "OpenSSL not found, building without SSL support") endif() if(ANDROID) diff --git a/client/client_connection.hpp b/client/client_connection.hpp index 4e603f75..33a3376a 100644 --- a/client/client_connection.hpp +++ b/client/client_connection.hpp @@ -31,7 +31,9 @@ #include #include #include +#ifdef HAS_OPENSSL #include +#endif #include // standard headers @@ -46,9 +48,9 @@ namespace beast = boost::beast; // from namespace websocket = beast::websocket; // from using tcp_socket = boost::asio::ip::tcp::socket; -using ssl_socket = boost::asio::ssl::stream; using tcp_websocket = websocket::stream; #ifdef HAS_OPENSSL +using ssl_socket = boost::asio::ssl::stream; using ssl_websocket = websocket::stream; #endif diff --git a/common/CMakeLists.txt b/common/CMakeLists.txt index d8c18232..330b4da3 100644 --- a/common/CMakeLists.txt +++ b/common/CMakeLists.txt @@ -5,8 +5,6 @@ if(NOT WIN32 AND NOT ANDROID) list(APPEND SOURCES daemon.cpp) endif() -include_directories(${OPENSSL_INCLUDE_DIR}) - if(SOXR_FOUND) include_directories(${SOXR_INCLUDE_DIRS}) endif(SOXR_FOUND) From 0beaa09e4f6c1a30c92ff82f78190aa9cd0782a0 Mon Sep 17 00:00:00 2001 From: badaix Date: Tue, 28 Jan 2025 21:09:56 +0100 Subject: [PATCH 20/37] Update documentation --- client/snapclient.cpp | 4 ++-- doc/configuration.md | 12 ++++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/client/snapclient.cpp b/client/snapclient.cpp index 0d5657c6..3297dfe3 100644 --- a/client/snapclient.cpp +++ b/client/snapclient.cpp @@ -153,12 +153,12 @@ int main(int argc, char** argv) auto port_opt = op.add>("p", "port", "(deprecated, use [url]) Server port", 1704, &settings.server.port); op.add>("i", "instance", "Instance id when running multiple instances on the same host", 1, &settings.instance); op.add>("", "hostID", "Unique host id, default is MAC address", "", &settings.host_id); - auto server_cert_opt = - op.add>("", "server-cert", "Verify server with certificate (PEM format)", "default certificates"); op.add>("", "cert", "Client certificate file (PEM format)", settings.server.certificate, &settings.server.certificate); op.add>("", "cert-key", "Client private key file (PEM format)", settings.server.certificate_key, &settings.server.certificate_key); op.add>("", "key-password", "Key password (for encrypted private key)", settings.server.key_password, &settings.server.key_password); + auto server_cert_opt = + op.add>("", "server-cert", "Verify server with CA certificate (PEM format)", "default certificates"); // PCM device specific #if defined(HAS_ALSA) || defined(HAS_PULSE) || defined(HAS_WASAPI) diff --git a/doc/configuration.md b/doc/configuration.md index 510f65e5..1e7a464a 100644 --- a/doc/configuration.md +++ b/doc/configuration.md @@ -329,8 +329,12 @@ Snapserver supports RPC via HTTP(S) and WS(S) as well as audio streaming over WS ### HTTPS +#### Server + For HTTPS/WSS, the paramter `ssl_enabled` must be set to `true` (default: `false`) and the `certificate` and `certificate_key` paramters in the `[ssl]` section must point to a certificate file and key file in PEM format. +If you want only trusted clients being able to connect, the parameter `verify_clients` must be set to `true` and the client CA certificates must be configures as list of `client_cert =` entries. + Some hints on how to create a certificate and a private key are given for instance here: - [Create Root CA (Done once)](https://gist.github.com/fntlnz/cf14feb5a46b2eda428e000157447309) @@ -382,3 +386,11 @@ certificate_key = snapserver.key ``` Install the CA certificate `snapcastCA.crt` on you client's OS or browser. + +#### Client + +To use an SSL connection to the server, the client must use the secure websockets URI: `snapclient [options...] wss://[:port]`. + +To enable server authentication, the server CA certificate can be configured with `--server-cert=`. + +If the server is confgured to authenticate the clients (`verify_clients = true` in `snapserver.conf`), you must configure the client certificate and private key with `--cert=` and `--cert-key=`. From b7aab73781f5652de32bb4fa3d4c402bb93d0364 Mon Sep 17 00:00:00 2001 From: badaix Date: Tue, 28 Jan 2025 22:00:57 +0100 Subject: [PATCH 21/37] Use different caches for Windows CI --- .github/workflows/ci.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3eed82be..ec5d6509 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -401,7 +401,8 @@ jobs: with: #path: ${VCPKG_INSTALLATION_ROOT}\installed path: c:\vcpkg\installed - key: ${{ runner.os }}-dependencies + key: ${{ runner.os }}-${{ matrix.compiler }}-${{ matrix.build_type }}-ccache-${{ github.sha }} + restore-keys: ${{ runner.os }}-${{ matrix.compiler }}-${{ matrix.build_type }}-ccache- - name: dependencies if: steps.cache-dependencies.outputs.cache-hit != 'true' run: | From d40d86fb68a5bd549c5c30a8a193134833a6f680 Mon Sep 17 00:00:00 2001 From: badaix Date: Tue, 28 Jan 2025 22:03:40 +0100 Subject: [PATCH 22/37] Update changelog --- changelog.md | 1 + 1 file changed, 1 insertion(+) diff --git a/changelog.md b/changelog.md index 6d41052e..e7de162d 100644 --- a/changelog.md +++ b/changelog.md @@ -5,6 +5,7 @@ ### Features - Client: Add support for (secure-) websockets (Issue #1325) +- Server: Add client authentication (Issue #1334) ### Bugfixes From a69e97eb530c699fa9839f2f7cd87d9928f31f1d Mon Sep 17 00:00:00 2001 From: badaix Date: Wed, 29 Jan 2025 21:43:22 +0100 Subject: [PATCH 23/37] Bump version to v0.31.100 --- CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index b680c360..f0fbb2f3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -8,7 +8,7 @@ endif() project( snapcast LANGUAGES CXX - VERSION 0.31.0) + VERSION 0.31.100) set(PROJECT_DESCRIPTION "Multiroom client-server audio player") set(PROJECT_URL "https://github.com/badaix/snapcast") From 77d23f627d7f0d955d06eba9152b48dbe64c2251 Mon Sep 17 00:00:00 2001 From: badaix Date: Wed, 29 Jan 2025 22:38:59 +0100 Subject: [PATCH 24/37] Set default protocol to "tcp" --- client/client_settings.hpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/client_settings.hpp b/client/client_settings.hpp index 0e671c00..096cd597 100644 --- a/client/client_settings.hpp +++ b/client/client_settings.hpp @@ -63,7 +63,7 @@ struct ClientSettings /// server host or IP address std::string host; /// protocol: "tcp", "ws" or "wss" - std::string protocol; + std::string protocol{"tcp"}; /// server port size_t port{1704}; /// server certificate From fb8f6b87b8c5cfd926eff8ba428e1940dd87331a Mon Sep 17 00:00:00 2001 From: badaix Date: Thu, 30 Jan 2025 09:33:59 +0100 Subject: [PATCH 25/37] Change log line --- client/client_connection.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/client_connection.cpp b/client/client_connection.cpp index 73c06d14..e47a8818 100644 --- a/client/client_connection.cpp +++ b/client/client_connection.cpp @@ -129,7 +129,7 @@ void ClientConnection::connect(const ResultHandler& handler) for (const auto& iter : iterator) { - LOG(INFO, LOG_TAG) << "Connecting to " << server_.protocol << "://" << iter.endpoint() << "\n"; + LOG(INFO, LOG_TAG) << "Connecting to host: " << iter.endpoint() << ", port: " << server_.port << ", protocol: " << server_.protocol << "\n"; ec = doConnect(iter.endpoint()); if (!ec || (ec == boost::system::errc::interrupted)) { From c2bebb4baef233954b12b65e4718897c3869c205 Mon Sep 17 00:00:00 2001 From: badaix Date: Thu, 30 Jan 2025 20:38:31 +0100 Subject: [PATCH 26/37] Update binary_protocol.md --- doc/binary_protocol.md | 38 ++++++++++++++++++++++++++++---------- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/doc/binary_protocol.md b/doc/binary_protocol.md index f0f77590..ffc0a6f0 100644 --- a/doc/binary_protocol.md +++ b/doc/binary_protocol.md @@ -1,6 +1,7 @@ # Snapcast binary protocol Each message sent with the Snapcast binary protocol is split up into two parts: + - A base message that provides general information like time sent/received, type of the message, message size, etc - A typed message that carries the rest of the information @@ -24,15 +25,15 @@ When a client joins a server, the following exchanges happen ## Messages -| Typed Message ID | Name | Notes | -|------------------|--------------------------------------|---------------------------------------------------------------------------| -| 0 | [Base](#base) | The beginning of every message containing data about the typed message | -| 1 | [Codec Header](#codec-header) | The codec-specific data to put at the start of a stream to allow decoding | -| 2 | [Wire Chunk](#wire-chunk) | A part of an audio stream | -| 3 | [Server Settings](#server-settings) | Settings set from the server like volume, latency, etc | -| 4 | [Time](#time) | Used for synchronizing time with the server | -| 5 | [Hello](#hello) | Sent by the client when connecting with the server | -| 6 | [Stream Tags](#stream-tags) | Metadata about the stream for use by the client | +| Typed Message ID | Name | Dir | Notes | +|------------------|--------------------------------------|------|---------------------------------------------------------------------------| +| 0 | [Base](#base) | | The beginning of every message containing data about the typed message | +| 1 | [Codec Header](#codec-header) | S->C | The codec-specific data to put at the start of a stream to allow decoding | +| 2 | [Wire Chunk](#wire-chunk) | S->C | A part of an audio stream | +| 3 | [Server Settings](#server-settings) | S->C | Settings set from the server like volume, latency, etc | +| 4 | [Time](#time) | C->S
S->C | Used for synchronizing time with the server | +| 5 | [Hello](#hello) | C->S | Sent by the client when connecting with the server | +| 7 | [Client Info](#client-info) | C->S | Update the server when relevant information changes (e.g. client volume) | ### Base @@ -63,7 +64,6 @@ The payload depends on the used codec: - PCM: a RIFF WAVE header, as described [here](https://de.wikipedia.org/wiki/RIFF_WAVE). PCM is not encoded, but the decoder must know the samplerate, bit depth and number of channels, which is encoded into the header - Opus: a dummy header is sent, containing a 4 byte ID (0x4F505553, ascii for "OPUS"), 4 byte samplerate, 2 byte bit depth, 2 byte channel count (all little endian) - ### Wire Chunk | Field | Type | Description | @@ -122,3 +122,21 @@ Sample JSON payload (whitespace added for readability): "Version": "0.17.1" } ``` + +### Client Info + +| Field | Type | Description | +|---------|--------|----------------------------------------------------------| +| size | uint32 | Size of the following JSON string | +| payload | char[] | JSON string containing the message (not null terminated) | + +Sample JSON payload (whitespace added for readability): + +```json +{ + "volume": 100, + "muted": false, +} +``` + +- `volume` can have a value between 0-100 inclusive From b773ccda18f2baa5c2ded004b460da19353d4d5f Mon Sep 17 00:00:00 2001 From: badaix Date: Thu, 30 Jan 2025 20:47:09 +0100 Subject: [PATCH 27/37] Add code documentation --- changelog.md | 4 +-- common/message/client_info.hpp | 21 ++++++++++----- common/message/codec_header.hpp | 19 ++++++++------ common/message/factory.hpp | 11 +++++--- common/message/hello.hpp | 28 ++++++++++++++------ common/message/json_message.hpp | 16 +++++++----- common/message/pcm_chunk.hpp | 42 +++++++++++++++++++++--------- common/message/server_settings.hpp | 25 +++++++++++------- common/message/time.hpp | 13 ++++----- 9 files changed, 116 insertions(+), 63 deletions(-) diff --git a/changelog.md b/changelog.md index e7de162d..3ce8364d 100644 --- a/changelog.md +++ b/changelog.md @@ -4,8 +4,8 @@ ### Features -- Client: Add support for (secure-) websockets (Issue #1325) -- Server: Add client authentication (Issue #1334) +- Client: Add support for (secure-) websockets (Issue #1325, PR #1340) +- Server: Add client authentication (Issue #1334, PR #1340) ### Bugfixes diff --git a/common/message/client_info.hpp b/common/message/client_info.hpp index 06331841..2e388743 100644 --- a/common/message/client_info.hpp +++ b/common/message/client_info.hpp @@ -1,6 +1,6 @@ /*** This file is part of snapcast - Copyright (C) 2014-2022 Johannes Pohl + Copyright (C) 2014-2025 Johannes Pohl This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by @@ -16,8 +16,7 @@ along with this program. If not, see . ***/ -#ifndef MESSAGE_CLIENT_INFO_HPP -#define MESSAGE_CLIENT_INFO_HPP +#pragma once // local headers #include "json_message.hpp" @@ -27,39 +26,47 @@ namespace msg { /// Client information sent from client to server -/// Might also be used for sync stats and latency estimations +/// Might also be used for other things in future, like +/// - sync stats +/// - latency estimations +/// - Battery status +/// - ... class ClientInfo : public JsonMessage { public: + /// c'tor ClientInfo() : JsonMessage(message_type::kClientInfo) { setVolume(100); setMuted(false); } + /// d'tor ~ClientInfo() override = default; + /// @return the volume in percent uint16_t getVolume() { return get("volume", static_cast(100)); } + /// @return if muted or not bool isMuted() { return get("muted", false); } + /// Set the volume to @p volume percent void setVolume(uint16_t volume) { msg["volume"] = volume; } + /// Set muted to @p muted void setMuted(bool muted) { msg["muted"] = muted; } }; + } // namespace msg - - -#endif diff --git a/common/message/codec_header.hpp b/common/message/codec_header.hpp index 3f91c971..743760c0 100644 --- a/common/message/codec_header.hpp +++ b/common/message/codec_header.hpp @@ -1,6 +1,6 @@ /*** This file is part of snapcast - Copyright (C) 2014-2022 Johannes Pohl + Copyright (C) 2014-2025 Johannes Pohl This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by @@ -16,8 +16,8 @@ along with this program. If not, see . ***/ -#ifndef MESSAGE_CODEC_HEADER_HPP -#define MESSAGE_CODEC_HEADER_HPP +#pragma once + // local headers #include "message.hpp" @@ -31,13 +31,15 @@ namespace msg class CodecHeader : public BaseMessage { public: - explicit CodecHeader(const std::string& codecName = "", uint32_t size = 0) - : BaseMessage(message_type::kCodecHeader), payloadSize(size), payload(nullptr), codec(codecName) + /// c'tor taking the @p codec_name and @p site of the payload + explicit CodecHeader(const std::string& codec_name = "", uint32_t size = 0) + : BaseMessage(message_type::kCodecHeader), payloadSize(size), payload(nullptr), codec(codec_name) { if (size > 0) payload = static_cast(malloc(size * sizeof(char))); } + /// d'tor ~CodecHeader() override { free(payload); @@ -54,8 +56,11 @@ public: return static_cast(sizeof(uint32_t) + codec.size() + sizeof(uint32_t) + payloadSize); } + /// payload size uint32_t payloadSize; + /// the payload char* payload; + /// name of the codec std::string codec; protected: @@ -65,7 +70,5 @@ protected: writeVal(stream, payload, payloadSize); } }; + } // namespace msg - - -#endif diff --git a/common/message/factory.hpp b/common/message/factory.hpp index ac236ed6..767fe945 100644 --- a/common/message/factory.hpp +++ b/common/message/factory.hpp @@ -1,6 +1,6 @@ /*** This file is part of snapcast - Copyright (C) 2014-2024 Johannes Pohl + Copyright (C) 2014-2025 Johannes Pohl This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by @@ -30,15 +30,16 @@ namespace msg { +/// Cast a BaseMessage @message to type "ToType" +/// @return castest message or nullptr, if the cast failed template static std::unique_ptr message_cast(std::unique_ptr message) { - ToType* tmp = dynamic_cast(message.get()); - std::unique_ptr result; + auto* tmp = dynamic_cast(message.get()); if (tmp != nullptr) { message.release(); - result.reset(tmp); + std::unique_ptr result(tmp); return result; } return nullptr; @@ -47,6 +48,7 @@ static std::unique_ptr message_cast(std::unique_ptr me namespace factory { +/// Create a message of type T from @p base_message beaser and payload @p buffer template static std::unique_ptr createMessage(const BaseMessage& base_message, char* buffer) { @@ -57,6 +59,7 @@ static std::unique_ptr createMessage(const BaseMessage& base_message, char* b return result; } +/// Create a BaseMessage from @p base_message header and payload @p buffer static std::unique_ptr createMessage(const BaseMessage& base_message, char* buffer) { std::unique_ptr result; diff --git a/common/message/hello.hpp b/common/message/hello.hpp index 3deb161e..5e768319 100644 --- a/common/message/hello.hpp +++ b/common/message/hello.hpp @@ -1,6 +1,6 @@ /*** This file is part of snapcast - Copyright (C) 2014-2022 Johannes Pohl + Copyright (C) 2014-2025 Johannes Pohl This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by @@ -16,8 +16,7 @@ along with this program. If not, see . ***/ -#ifndef MESSAGE_HELLO_HPP -#define MESSAGE_HELLO_HPP +#pragma once // local headers #include "common/str_compat.hpp" @@ -31,16 +30,20 @@ namespace msg { +/// Hello message +/// Initial message, sent from client to server class Hello : public JsonMessage { public: + /// c'tor Hello() : JsonMessage(message_type::kHello) { } - Hello(const std::string& macAddress, const std::string& id, size_t instance) : JsonMessage(message_type::kHello) + /// c'tor taking @p macAddress, @p id and @p instance + Hello(const std::string& mac_address, const std::string& id, size_t instance) : JsonMessage(message_type::kHello) { - msg["MAC"] = macAddress; + msg["MAC"] = mac_address; msg["HostName"] = ::getHostName(); msg["Version"] = VERSION; msg["ClientName"] = "Snapclient"; @@ -51,53 +54,64 @@ public: msg["SnapStreamProtocolVersion"] = 2; } + /// d'tor ~Hello() override = default; + /// @return the MAC address std::string getMacAddress() const { return msg["MAC"]; } + /// @return the host name std::string getHostName() const { return msg["HostName"]; } + /// @return the client version std::string getVersion() const { return msg["Version"]; } + /// @return the client name (e.g. "Snapclient") std::string getClientName() const { return msg["ClientName"]; } + /// @return the OS name std::string getOS() const { return msg["OS"]; } + /// @return the CPU architecture std::string getArch() const { return msg["Arch"]; } + /// @return the instance id int getInstance() const { return get("Instance", 1); } + /// @return the protocol version int getProtocolVersion() const { return get("SnapStreamProtocolVersion", 1); } + /// @return a unqiue machine ID std::string getId() const { return get("ID", getMacAddress()); } + /// @return a unqiue client ID std::string getUniqueId() const { std::string id = getId(); @@ -109,7 +123,5 @@ public: return id; } }; + } // namespace msg - - -#endif diff --git a/common/message/json_message.hpp b/common/message/json_message.hpp index 127a1d33..a9829ff2 100644 --- a/common/message/json_message.hpp +++ b/common/message/json_message.hpp @@ -1,6 +1,6 @@ /*** This file is part of snapcast - Copyright (C) 2014-2022 Johannes Pohl + Copyright (C) 2014-2025 Johannes Pohl This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by @@ -16,8 +16,8 @@ along with this program. If not, see . ***/ -#ifndef MESSAGE_JSON_HPP -#define MESSAGE_JSON_HPP +#pragma once + // local headers #include "common/json.hpp" @@ -30,13 +30,16 @@ using json = nlohmann::json; namespace msg { +/// Base class of a message with json payload class JsonMessage : public BaseMessage { public: - JsonMessage(message_type msgType) : BaseMessage(msgType) + /// c'tor taking the @p msg_type + explicit JsonMessage(message_type msg_type) : BaseMessage(msg_type) { } + /// d'tor ~JsonMessage() override = default; void read(std::istream& stream) override @@ -60,6 +63,7 @@ protected: writeVal(stream, msg.dump()); } + /// @return value for key @p what or @p def, if not found template T get(const std::string& what, const T& def) const { @@ -75,7 +79,5 @@ protected: } } }; + } // namespace msg - - -#endif diff --git a/common/message/pcm_chunk.hpp b/common/message/pcm_chunk.hpp index df4e66aa..5454d5c2 100644 --- a/common/message/pcm_chunk.hpp +++ b/common/message/pcm_chunk.hpp @@ -38,15 +38,17 @@ namespace msg class PcmChunk : public WireChunk { public: - PcmChunk(const SampleFormat& sampleFormat, uint32_t ms) - : WireChunk((sampleFormat.rate() * ms / 1000) * sampleFormat.frameSize()), format(sampleFormat), idx_(0) + /// c'tor, construct from @p sample_format with duration @p ms + PcmChunk(const SampleFormat& sample_format, uint32_t ms) : WireChunk((sample_format.rate() * ms / 1000) * sample_format.frameSize()), format(sample_format) { } - PcmChunk() : WireChunk(), idx_(0) + /// c'tor + PcmChunk() : WireChunk() { } + /// d'tor ~PcmChunk() override = default; #if 0 @@ -73,16 +75,18 @@ public: // return result; // } - int readFrames(void* outputBuffer, uint32_t frameCount) + /// Read the next @p frame_count frames into @p output_buffer, update the internal read position + /// @return number of frames copied to @p output_buffer + int readFrames(void* output_buffer, uint32_t frame_count) { // logd << "read: " << frameCount << ", total: " << (wireChunk->length / format.frameSize()) << ", idx: " << idx;// << "\n"; - int result = frameCount; - if (idx_ + frameCount > (payloadSize / format.frameSize())) + int result = frame_count; + if (idx_ + frame_count > (payloadSize / format.frameSize())) result = (payloadSize / format.frameSize()) - idx_; // logd << ", from: " << format.frameSize()*idx << ", to: " << format.frameSize()*idx + format.frameSize()*result; - if (outputBuffer != nullptr) - memcpy(static_cast(outputBuffer), static_cast(payload) + format.frameSize() * idx_, format.frameSize() * result); + if (output_buffer != nullptr) + memcpy(static_cast(output_buffer), static_cast(payload) + format.frameSize() * idx_, format.frameSize() * result); idx_ += result; // logd << ", new idx: " << idx << ", result: " << result << ", wireChunk->length: " << wireChunk->length << ", format.frameSize(): " << @@ -90,6 +94,8 @@ public: return result; } + /// seek @p frames forward or backward + /// @return the new read position int seek(int frames) { if ((frames < 0) && (-frames > static_cast(idx_))) @@ -102,17 +108,20 @@ public: return idx_; } + /// @return start time of the current frame chronos::time_point_clk start() const override { return chronos::time_point_clk(chronos::sec(timestamp.sec) + chronos::usec(timestamp.usec) + chronos::usec(static_cast(1000000. * ((double)idx_ / (double)format.rate())))); } + /// @return time of the last frame inline chronos::time_point_clk end() const { return start() + durationLeft(); } + /// @return duration of this chunk template inline T duration() const { @@ -127,42 +136,51 @@ public: // payloadSize = newSize; // } - void setFrameCount(int frameCount) + /// Set the @p frame_count, reserve memory + void setFrameCount(int frame_count) { - auto newSize = format.frameSize() * frameCount; - payload = static_cast(realloc(payload, newSize)); - payloadSize = newSize; + auto new_size = format.frameSize() * frame_count; + payload = static_cast(realloc(payload, new_size)); + payloadSize = new_size; } + /// @return duration of this chunk in [ms] double durationMs() const { return static_cast(getFrameCount()) / format.msRate(); } + /// @return time left, starting from the read pointer template inline T durationLeft() const { return std::chrono::duration_cast(chronos::nsec(static_cast(1000000 * (getFrameCount() - idx_) / format.msRate()))); } + /// @return true if the read pointer is at the end inline bool isEndOfChunk() const { return idx_ >= getFrameCount(); } + /// @return number of frames inline uint32_t getFrameCount() const { return (payloadSize / format.frameSize()); } + /// @return number of samples inline uint32_t getSampleCount() const { return (payloadSize / format.sampleSize()); } + /// Sample format of this chunk SampleFormat format; private: + /// current read position (frame idx) uint32_t idx_ = 0; }; + } // namespace msg diff --git a/common/message/server_settings.hpp b/common/message/server_settings.hpp index 0979118a..cff96977 100644 --- a/common/message/server_settings.hpp +++ b/common/message/server_settings.hpp @@ -1,6 +1,6 @@ /*** This file is part of snapcast - Copyright (C) 2014-2022 Johannes Pohl + Copyright (C) 2014-2025 Johannes Pohl This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by @@ -16,8 +16,7 @@ along with this program. If not, see . ***/ -#ifndef MESSAGE_SERVER_SETTINGS_HPP -#define MESSAGE_SERVER_SETTINGS_HPP +#pragma once // local headers #include "json_message.hpp" @@ -26,9 +25,11 @@ namespace msg { +/// Dynamic settings that affect the client class ServerSettings : public JsonMessage { public: + /// c'tor ServerSettings() : JsonMessage(message_type::kServerSettings) { setBufferMs(0); @@ -37,51 +38,57 @@ public: setMuted(false); } + /// d'tor ~ServerSettings() override = default; + /// @return the end to end delay in [ms] int32_t getBufferMs() { return get("bufferMs", 0); } + /// @return client specific additional latency in [ms] int32_t getLatency() { return get("latency", 0); } + /// @return the volume in [%] uint16_t getVolume() { return get("volume", static_cast(100)); } + /// @return if muted bool isMuted() { return get("muted", false); } - - void setBufferMs(int32_t bufferMs) + /// Set the end to end delay to @p buffer_ms [ms] + void setBufferMs(int32_t buffer_ms) { - msg["bufferMs"] = bufferMs; + msg["bufferMs"] = buffer_ms; } + /// Set the additional client specific @p latency [ms] void setLatency(int32_t latency) { msg["latency"] = latency; } + /// Set the @p volume [%] void setVolume(uint16_t volume) { msg["volume"] = volume; } + /// Set client to @p muted void setMuted(bool muted) { msg["muted"] = muted; } }; + } // namespace msg - - -#endif diff --git a/common/message/time.hpp b/common/message/time.hpp index 3abc18bb..52cf8c7b 100644 --- a/common/message/time.hpp +++ b/common/message/time.hpp @@ -1,6 +1,6 @@ /*** This file is part of snapcast - Copyright (C) 2014-2022 Johannes Pohl + Copyright (C) 2014-2025 Johannes Pohl This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by @@ -16,8 +16,7 @@ along with this program. If not, see . ***/ -#ifndef MESSAGE_TIME_HPP -#define MESSAGE_TIME_HPP +#pragma once // local headers #include "message.hpp" @@ -25,13 +24,16 @@ namespace msg { +/// Time sync message, send from client to server and back class Time : public BaseMessage { public: + /// c'tor Time() : BaseMessage(message_type::kTime) { } + /// d'tor ~Time() override = default; void read(std::istream& stream) override @@ -45,6 +47,7 @@ public: return sizeof(tv); } + /// The latency after round trip "client => server => client" tv latency; protected: @@ -54,7 +57,5 @@ protected: writeVal(stream, latency.usec); } }; + } // namespace msg - - -#endif From b57ead5037bd53fa7cc2bd12dca1de6db92d59bb Mon Sep 17 00:00:00 2001 From: Christopher Nethercott Date: Thu, 30 Jan 2025 18:40:00 +0000 Subject: [PATCH 28/37] Fix mispelling of normalize in snapserver.conf --- server/etc/snapserver.conf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/etc/snapserver.conf b/server/etc/snapserver.conf index 0c0d275b..a899904b 100644 --- a/server/etc/snapserver.conf +++ b/server/etc/snapserver.conf @@ -159,7 +159,7 @@ doc_root = /usr/share/snapserver/snapweb # and will override the default codec, sampleformat or chunk_ms settings # Available types are: # pipe: pipe:///?name=[&mode=create], mode can be "create" or "read" -# librespot: librespot:///?name=[&username=&password=][&devicename=Snapcast][&bitrate=320][&wd_timeout=7800][&volume=100][&onevent=""][&nomalize=false][&autoplay=false][¶ms=] +# librespot: librespot:///?name=[&username=&password=][&devicename=Snapcast][&bitrate=320][&wd_timeout=7800][&volume=100][&onevent=""][&normalize=false][&autoplay=false][¶ms=] # note that you need to have the librespot binary on your machine # sampleformat will be set to "44100:16:2" # file: file:///?name= From 6a9d53f3f25b9042a47f3dae823215692a1d552e Mon Sep 17 00:00:00 2001 From: badaix Date: Thu, 30 Jan 2025 22:58:01 +0100 Subject: [PATCH 29/37] Update changelog --- changelog.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/changelog.md b/changelog.md index 3ce8364d..15328017 100644 --- a/changelog.md +++ b/changelog.md @@ -9,11 +9,12 @@ ### Bugfixes -- Fix typo in documentation (PR #1333) +- Fix typos (PR #1333, PR #1341) ### General - Client: Command line arguments '--host' and '--port' are deprecated +- Update binary_protocol.md (Issue #1339) _Johannes Pohl Thu, 23 Jan 2025 00:13:37 +0200_ From dfa9cb6fbc83a9ca162990753f95716251dc2aab Mon Sep 17 00:00:00 2001 From: badaix Date: Thu, 30 Jan 2025 22:58:39 +0100 Subject: [PATCH 30/37] Update max doxygen warnings --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ec5d6509..b48c4563 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,7 +75,7 @@ jobs: mkdir -p build/doxygen doxygen 2>&1 | tee build/doxygen.log WARNINGS=$(cat build/doxygen.log | sort | uniq | grep -e ": warning: " | wc -l) - MAX_ALLOWED=693 + MAX_ALLOWED=593 echo "Doxygen finished with $WARNINGS warnings, max allowed: $MAX_ALLOWED" if [ "$WARNINGS" -gt "$MAX_ALLOWED" ]; then exit $WARNINGS; else exit 0; fi; From 648589a233253a6e8d220d64c22882fde3e8db73 Mon Sep 17 00:00:00 2001 From: badaix Date: Tue, 4 Feb 2025 19:01:33 +0100 Subject: [PATCH 31/37] Remove README.meta --- server/streamreader/README.meta | 34 --------------------------------- 1 file changed, 34 deletions(-) delete mode 100644 server/streamreader/README.meta diff --git a/server/streamreader/README.meta b/server/streamreader/README.meta deleted file mode 100644 index 102535b0..00000000 --- a/server/streamreader/README.meta +++ /dev/null @@ -1,34 +0,0 @@ -Metadata tags -============= -The metatag interface will propegate anything to the client, ie no restrictions on the tags. -But, to avoid total confusion in the tag display app (like Kodi), we need some groundrules. - -My suggestion is to stick with the Vorbis standard as described in: - - http://www.xiph.org/vorbis/doc/v-comment.html - https://wiki.xiph.org/Field_names - -In addition we should accept unique identifiers of the stream such as Spotify track ID or -MusicBrainz id which can be used to lookup additional information online. - -Example: - ARTIST: Pink Floyd - ALBUM: Dark Side of the Moon - TITLE: Money - METADATA_BLOCK_PICTURE: base64 (https://xiph.org/flac/format.html#metadata_block_picture) - - -Parsing tags from streams -========================= -I've implemented parsing tags from Librespot, but, as Librespot doesn't export anything more than -track title I added a patch 'librespot-meta.patch' to apply to get artist/title. - -TBH, the meta tag output from players are a mess, neither Librespot nor Shairport-sync associate -metadata with their audio interfaces, ie they don't even export metadata when the audio interface -supports it, at least Shairport-sync exports a ritch set of data. - -So, to get artist/album apply the patch to librespot and recompile, otherwise you will only get the -track title. - -Or, you can build librespot from https://github.com/frafall/librespot.git - From 8d7e4ba27859b42b85edc88ac068c49bff8bc944 Mon Sep 17 00:00:00 2001 From: Raphael Nestler Date: Tue, 4 Feb 2025 15:13:28 +0100 Subject: [PATCH 32/37] Fix copy paste error in AirplayStream docs --- server/streamreader/airplay_stream.hpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/streamreader/airplay_stream.hpp b/server/streamreader/airplay_stream.hpp index 192374e6..939a1f6b 100644 --- a/server/streamreader/airplay_stream.hpp +++ b/server/streamreader/airplay_stream.hpp @@ -49,7 +49,7 @@ public: /// Starts shairport-sync and reads PCM data from stdout /** - * Starts librespot, reads PCM data from stdout, and passes the data to an encoder. + * Starts shairport-sync, reads PCM data from stdout, and passes the data to an encoder. * Implements EncoderListener to get the encoded data. * Data is passed to the PcmStream::Listener * usage: From 498f878aeab0df1cf1c045325d0f1283cd588376 Mon Sep 17 00:00:00 2001 From: Francesco <66080458+chicco-carone@users.noreply.github.com> Date: Wed, 29 Jan 2025 21:41:45 +0100 Subject: [PATCH 33/37] Create plex_bridge.py Create a plug-in to add track info and controls to the snapserver --- server/etc/plug-ins/plex_bridge.py | 334 +++++++++++++++++++++++++++++ 1 file changed, 334 insertions(+) create mode 100644 server/etc/plug-ins/plex_bridge.py diff --git a/server/etc/plug-ins/plex_bridge.py b/server/etc/plug-ins/plex_bridge.py new file mode 100644 index 00000000..a1892f61 --- /dev/null +++ b/server/etc/plug-ins/plex_bridge.py @@ -0,0 +1,334 @@ +#!/usr/bin/env python3 + +import json +import sys +import logging +import argparse +import requests +import time +from typing import Dict, Any, Optional, Union +from plexapi.server import PlexServer +from plexapi.client import PlexClient +from plexapi.exceptions import NotFound +import threading + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(levelname)s - %(message)s", + filename="/tmp/plex_bridge.log" +) + +class PlexSnapcastBridge: + def __init__(self, config: Dict[str, Union[str, Optional[str]]]): + self.config = config + self.plex: Optional[PlexServer] = None + self.current_player: Optional[PlexClient] = None + self.snapcast_url: Optional[str] = None + self.last_metadata: Dict[str, Any] = {} + self.running = True + if self.config.get("snapcast_host") and self.config.get("snapcast_port"): + self.snapcast_url = f"http://{self.config['snapcast_host']}:{self.config['snapcast_port']}/jsonrpc" + self.connect_to_plex() + self.send_ready_notification() + self.monitor_thread = threading.Thread(target=self.monitor_sessions, daemon=True) + self.monitor_thread.start() + + def connect_to_plex(self) -> None: + try: + baseurl = f"http://{self.config['ip']}:32400" + token = self.config["token"] + self.plex = PlexServer(baseurl, token) + self.current_player = self.plex.client(self.config["player"]) + self.send_log("Connected to Plex server") + except NotFound: + self.send_log(f"Player {self.config['player']} not found", "error") + raise + except Exception as e: + self.send_log(f"Failed to connect to Plex: {str(e)}", "error") + raise + + def update_snapcast_status(self, properties: Dict[str, Any]) -> None: + if not self.snapcast_url: + return + + try: + payload = { + "id": 1, + "jsonrpc": "2.0", + "method": "Stream.OnProperties", + "params": { + "id": self.config.get("stream", "Plex"), + "properties": properties + } + } + requests.post(self.snapcast_url, json=payload) + except requests.exceptions.RequestException as e: + self.send_log(f"Failed to update Snapcast status: {str(e)}", "error") + + def send_ready_notification(self) -> None: + notification = { + "jsonrpc": "2.0", + "method": "Plugin.Stream.Ready" + } + self._send_json(notification) + + def _send_json(self, data: Dict[str, Any]) -> None: + try: + json_str = json.dumps(data) + print(json_str, flush=True) + logging.debug(f"Sent: {json_str}") + except Exception as e: + logging.error(f"Error sending JSON: {str(e)}") + + def send_properties_notification(self, properties: Dict[str, Any]) -> None: + notification = { + "jsonrpc": "2.0", + "method": "Plugin.Stream.Player.Properties", + "params": properties + } + self._send_json(notification) + self.update_snapcast_status(properties) + + def send_log(self, message: str, severity: str = "info") -> None: + notification = { + "jsonrpc": "2.0", + "method": "Plugin.Stream.Log", + "params": { + "severity": severity, + "message": message + } + } + self._send_json(notification) + + def get_current_properties(self) -> Dict[str, Any]: + if not self.current_player or not self.plex: + return self.get_default_properties() + + try: + metadata: Dict[str, Any] = {} + playback_status = "stopped" + + now_playing = self.plex.sessions() + player_name = self.config["player"] + + active_session = None + for session in now_playing: + if hasattr(session, "players") and session.players: + if session.players[0].title == player_name: + active_session = session + raw_state = getattr(session.players[0], "state", "stopped") + playback_status = "playing" if raw_state == "playing" else "paused" + break + + if active_session: + art_url = None + if hasattr(active_session, "thumb") and active_session.thumb: + art_url = f"http://{self.config['ip']}:32400{active_session.thumb}?X-Plex-Token={self.config['token']}" + + metadata = { + "title": getattr(active_session, "title", "Unknown Title"), + "artist": [getattr(active_session, "originalTitle", None) or + getattr(active_session, "grandparentTitle", "Unknown Artist")], + "album": getattr(active_session, "parentTitle", "Unknown Album"), + "duration": getattr(active_session, "duration", 0) / 1000, + "trackNumber": int(getattr(active_session, "index", 0)) if getattr(active_session, "index", None) else 0, + "artUrl": art_url + } + + if art_url: + metadata["artUrl"] = art_url + if hasattr(active_session, "key"): + metadata["url"] = f"http://{self.config['ip']}:32400{active_session.key}?X-Plex-Token={self.config['token']}" + + return { + "canControl": True, + "canGoNext": True, + "canGoPrevious": True, + "canPlay": True, + "canPause": True, + "canSeek": False, + "playbackStatus": playback_status, + "loopStatus": "none", + "shuffle": False, + "volume": getattr(self.current_player, "volume", 100), + "mute": getattr(self.current_player, "isMuted", False), + "position": getattr(self.current_player, "viewOffset", 0) / 1000, + "rate": 1.0, + "metadata": metadata + } + + except Exception as e: + self.send_log(f"Error getting properties: {str(e)}", "error") + return self.get_default_properties() + + def get_default_properties(self) -> Dict[str, Any]: + return { + "canControl": True, + "canGoNext": True, + "canGoPrevious": True, + "canPlay": True, + "canPause": True, + "canSeek": False, + "playbackStatus": "stopped", + "loopStatus": "none", + "shuffle": False, + "volume": 100, + "mute": False, + "position": 0 + } + + def handle_control_command(self, command: str, params: Dict[str, Any]) -> Dict[str, Any]: + try: + if self.current_player is None: + raise Exception("No player connected") + + self.send_log(f"Executing command: {command} with params: {params}", "debug") + + if command == "play": + self.current_player.play() + elif command == "pause": + self.current_player.pause() + elif command == "stop": + self.current_player.stop() + elif command == "next": + self.current_player.skipNext() + elif command == "previous": + self.current_player.skipPrevious() + elif command == "seek": + if "offset" in params: + current_time = getattr(self.current_player, "viewOffset", 0) + new_time = int((current_time / 1000 + params["offset"]) * 1000) + self.current_player.seekTo(new_time) + elif command == "setPosition": + if "position" in params: + new_time = int(params["position"] * 1000) + self.current_player.seekTo(new_time) + + time.sleep(0.2) + properties = self.get_current_properties() + self.send_properties_notification(properties) + return {"status": "ok"} + + except Exception as e: + self.send_log(f"Error executing control command ({type(e).__name__}): {str(e)}", "error") + return {"error": {"code": -32000, "message": str(e)}} + + def handle_set_property(self, property_name: str, value: Any) -> Dict[str, Any]: + try: + if self.current_player is None: + raise Exception("No player connected") + if property_name == "volume": + self.current_player.setVolume(value) + elif property_name == "mute": + if value: + self.current_player.mute() + else: + self.current_player.unmute() + + self.send_properties_notification(self.get_current_properties()) + return {"status": "ok"} + except Exception as e: + self.send_log(f"Error setting property: {str(e)}", "error") + return {"error": {"code": -32000, "message": str(e)}} + + def handle_command(self, command: Dict[str, Any]) -> Dict[str, Any]: + try: + method = command.get("method") + cmd_id = command.get("id") + response: Dict[str, Any] = { + "jsonrpc": "2.0", + "id": cmd_id + } + + if method == "Plugin.Stream.Player.Control": + params = command.get("params", {}) + result = self.handle_control_command(params.get("command", ""), params.get("params", {})) + response["result"] = result + elif method == "Plugin.Stream.Player.SetProperty": + params = command.get("params", {}) + property_name = next(iter(params.keys())) + result = self.handle_set_property(property_name, params[property_name]) + response["result"] = result + elif method == "Plugin.Stream.Player.GetProperties": + response["result"] = self.get_current_properties() + else: + response["error"] = { + "code": -32601, + "message": f"Method not found: {method}" + } + + return response + except Exception as e: + self.send_log(f"Error processing command: {str(e)}", "error") + return { + "jsonrpc": "2.0", + "id": command.get("id"), + "error": { + "code": -32000, + "message": str(e) + } + } + + def monitor_sessions(self) -> None: + while self.running: + try: + properties = self.get_current_properties() + current_metadata = properties.get("metadata", {}) + + if current_metadata != self.last_metadata: + self.send_log("Track changed, updating properties", "debug") + self.send_properties_notification(properties) + self.last_metadata = current_metadata.copy() + + time.sleep(1) + except Exception as e: + self.send_log(f"Error in session monitor: {str(e)}", "error") + time.sleep(1) + + def run(self) -> None: + self.send_log("Starting Plex bridge") + try: + while self.running: + try: + line = sys.stdin.readline() + if not line: + self.send_log("Received EOF, exiting", "notice") + break + + command = json.loads(line) + logging.debug(f"Received command: {command}") + response = self.handle_command(command) + self._send_json(response) + except json.JSONDecodeError as e: + self.send_log(f"Invalid JSON received: {str(e)}", "error") + except Exception as e: + self.send_log(f"Error processing command: {str(e)}", "error") + finally: + self.running = False + self.monitor_thread.join(timeout=1) + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Plex to Snapcast Bridge") + parser.add_argument("--token", type=str, required=True, help="Plex token") + parser.add_argument("--ip", type=str, required=True, help="Plex server IP address") + parser.add_argument("--player", type=str, required=True, help="Plex player name") + parser.add_argument("--stream", type=str, help="Stream ID") + parser.add_argument("--snapcast-port", type=str, help="Snapcast HTTP port") + parser.add_argument("--snapcast-host", type=str, help="Snapcast HTTP host") + + args = parser.parse_args() + + try: + config: Dict[str, Union[str, Optional[str]]] = { + "token": args.token, + "ip": args.ip, + "player": args.player, + "stream": args.stream, + "snapcast_host": args.snapcast_host, + "snapcast_port": args.snapcast_port + } + bridge = PlexSnapcastBridge(config) + bridge.run() + except Exception as e: + logging.error(f"Fatal error: {str(e)}") + sys.exit(1) From eceb234e53a897be6ab77c084d6d3ab64dbaf442 Mon Sep 17 00:00:00 2001 From: Francesco <66080458+chicco-carone@users.noreply.github.com> Date: Sun, 9 Feb 2025 01:06:08 +0100 Subject: [PATCH 34/37] Update player_setup.md Add instructions to use plexamp and Plex control script. --- doc/player_setup.md | 46 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/doc/player_setup.md b/doc/player_setup.md index 150514e1..458cf803 100644 --- a/doc/player_setup.md +++ b/doc/player_setup.md @@ -321,3 +321,49 @@ Use `--aout afile` and `--audiofile-file` to pipe VLC's audio output to the snap ```sh vlc --no-video --aout afile --audiofile-file /tmp/snapfifo ``` + +## Plexamp + +Plexamp can be configured to use Snapcast for multi-room audio by redirecting its output to a FIFO pipe. This can be managed using a Plex control script for audio playback. + +### 1. Install Plexamp +Follow the [official instructions](https://www.plex.tv/plexamp/) to install Plexamp on your system. This method works with both **plexamp-headless** and the standalone **Plexamp** client. + +### 2. Create a loopback FIFO pipe +You need to use **Pipewire** or **Pulseaudio**: + +```sh +pactl load-module module-pipe-sink file=/tmp/snapfifo sink_name=Snapcast-Plexamp format=s16le rate=44100 +``` + +**Note:** Ensure that the virtual sink settings match those configured in Snapserver to avoid audio issues. With this setup, resampling will be handled by your audio server. + +### 3. Configure Plexamp to use the FIFO pipe + +1. Go to Settings > Playback > Audio Output, and ensure it is set to Pipewire (or Pulseaudio). +2. Set the sink volume to the maximum from your system settings or using the command: + +```sh +pactl set-sink-volume @DEFAULT_SINK@ 100% +``` + +### 4. Add a new source to Snapserver +Edit your `snapserver.conf` file to add a new stream source for the FIFO pipe: + +```ini +[stream] +source = pipe:///tmp/snapfifo?name=Plexamp +``` + +### 5. (Optional) Configure the control script + +1. Retrieve your Plex API token following the [official guide](https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/). + +2. Modify the `snapserver.conf` file to add the control script: + +```ini +[stream] +source = pipe:///tmp/snapfifo?name=Plexamp&sampleformat=44100:16:2&codec=flac&controlscript=plex_bridge.py&controlscriptparams=--token= --ip= --player= +``` + +This setup allows you to use Plexamp with Snapcast for synchronized multi-room audio playback. From 5034cc4404371090ce233b401557cc26fd521a7a Mon Sep 17 00:00:00 2001 From: badaix Date: Tue, 4 Feb 2025 21:05:04 +0100 Subject: [PATCH 35/37] Update changelog --- changelog.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.md b/changelog.md index 15328017..c3fa2e61 100644 --- a/changelog.md +++ b/changelog.md @@ -9,7 +9,7 @@ ### Bugfixes -- Fix typos (PR #1333, PR #1341) +- Fix typos (PR #1333, PR #1341, PR #1345) ### General From 9a11d2aacfe505088041743f6b92d486242232be Mon Sep 17 00:00:00 2001 From: badaix Date: Sun, 2 Mar 2025 20:18:53 +0100 Subject: [PATCH 36/37] Fix linter issues, add dependecy for plexapi --- doc/player_setup.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/doc/player_setup.md b/doc/player_setup.md index 458cf803..1eccaaab 100644 --- a/doc/player_setup.md +++ b/doc/player_setup.md @@ -327,9 +327,11 @@ vlc --no-video --aout afile --audiofile-file /tmp/snapfifo Plexamp can be configured to use Snapcast for multi-room audio by redirecting its output to a FIFO pipe. This can be managed using a Plex control script for audio playback. ### 1. Install Plexamp + Follow the [official instructions](https://www.plex.tv/plexamp/) to install Plexamp on your system. This method works with both **plexamp-headless** and the standalone **Plexamp** client. ### 2. Create a loopback FIFO pipe + You need to use **Pipewire** or **Pulseaudio**: ```sh @@ -348,6 +350,7 @@ pactl set-sink-volume @DEFAULT_SINK@ 100% ``` ### 4. Add a new source to Snapserver + Edit your `snapserver.conf` file to add a new stream source for the FIFO pipe: ```ini @@ -358,8 +361,8 @@ source = pipe:///tmp/snapfifo?name=Plexamp ### 5. (Optional) Configure the control script 1. Retrieve your Plex API token following the [official guide](https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/). - -2. Modify the `snapserver.conf` file to add the control script: +2. Install Python package [plexapi](https://pypi.org/project/PlexAPI) +3. Modify the `snapserver.conf` file to add the control script: ```ini [stream] From 40ad2bac0ac59930fbabfda7cad41c6b2482c658 Mon Sep 17 00:00:00 2001 From: badaix Date: Sun, 2 Mar 2025 20:27:49 +0100 Subject: [PATCH 37/37] Update changelog --- changelog.md | 1 + 1 file changed, 1 insertion(+) diff --git a/changelog.md b/changelog.md index c3fa2e61..bc931759 100644 --- a/changelog.md +++ b/changelog.md @@ -6,6 +6,7 @@ - Client: Add support for (secure-) websockets (Issue #1325, PR #1340) - Server: Add client authentication (Issue #1334, PR #1340) +- Server: Add control script for Plex (PR #1346) ### Bugfixes