diff --git a/CMakeLists.txt b/CMakeLists.txt index 2ff4b08..ebad254 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -36,7 +36,7 @@ endif() # sendspin-cpp -set(SENDSPIN_GIT_TAG "b0fcf5bb369b470ddc4987280c19f0919e9a9084" CACHE STRING +set(SENDSPIN_GIT_TAG "3a3722a8d2771526e68be3cccf5b99712c36ae91" CACHE STRING "sendspin-cpp git tag or commit to build against") # player, metadata, controller (group transport verbs) and source; no FORCE, so -D overrides still win. @@ -294,6 +294,7 @@ add_library(sendspin-cli-core STATIC src/null_sink.cpp src/null_source.cpp src/outbound.cpp + src/pairing_endpoint.cpp src/pcm_ring.cpp src/pcm_volume.cpp src/player_listener.cpp @@ -500,6 +501,7 @@ if(SENDSPIN_CLI_BUILD_TESTS) tests/discovery_test.cpp tests/hooks_test.cpp tests/null_sink_test.cpp + tests/pairing_endpoint_test.cpp tests/pcm_ring_test.cpp tests/pcm_volume_test.cpp tests/pipewire_sink_test.cpp diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 387951c..68535b2 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -34,7 +34,7 @@ passing such a URL today. ### 3. Upstream gaps in sendspin-cpp Each needs a library change; nothing this repo can call fixes them. Re-check on every -`SENDSPIN_GIT_TAG` bump (currently `b0fcf5bb`, past v0.9.5). +`SENDSPIN_GIT_TAG` bump (currently `3a3722a8`, past v0.9.5). - **No log sink hook.** The host `SS_LOG*` macros are bare `fprintf(stderr, ...)` gated on one global level. So library lines carry no timestamp under `-f`, cannot be filtered per tag diff --git a/docs/wiki/Advanced-Usage.md b/docs/wiki/Advanced-Usage.md index d301b44..887f8c1 100644 --- a/docs/wiki/Advanced-Usage.md +++ b/docs/wiki/Advanced-Usage.md @@ -302,6 +302,44 @@ $ journalctl -u sendspin-cli | grep 'Pairing token' It stops being logged once a server is paired, and it works whichever code method the player offers. +### Pairing with a server on the same host + +`--pairing-token-endpoint
:` (or `pairing-token-endpoint =`) serves the token +over HTTP so a server on the same host can fetch it and pair without anyone copying it. On +Home Assistant OS that address is the hassio network's `172.30.32.1`: + +``` +pairing-token-endpoint = 172.30.32.1:8930 +``` + +The player answers `GET /` with its token as plain text: + +``` +SP:0TNMH6… +``` + +and lists the role `_ohf_local_pairing@v1`, with `{"port": 8930}`, in its `client/hello` so a +server knows the endpoint is there and which port to ask. The role name is provisional. +Without the option nothing listens and no such role is advertised. + +How it stays local: + +- **It listens on one address only.** The address is required and must be a numeric IP; a + wildcard (`0.0.0.0`, `::`) or a bare port is refused at startup. Pick an address only the + server's host can reach, never the LAN address. +- **Anything that reaches it can pair.** On Home Assistant OS that is every app on the + hassio network, so turn the option on only where those are trusted. +- **A request must name it.** The `Host` header has to be the endpoint's own address and + port, so a web page that rebinds its hostname to that address gets no token. It serves at + most four connections at once and closes any beyond them. +- **The server decides where to fetch from.** It acts only for an unpaired player on an + encrypted connection, fetches from the internal address it knows rather than one the player + names, and refuses a token issued for a different client id. +- **The token stays out of the log.** Requests are logged at `debug` without it, and the + startup line that otherwise prints it points at `sendspin-cli pair-token` instead. + +If the address cannot be bound the player exits rather than advertising a port nobody serves. + ## Stream hooks `--hook-start` and `--hook-stop` run a shell command when a stream starts or stops, diff --git a/docs/wiki/Configuration.md b/docs/wiki/Configuration.md index 7d12dec..d02824e 100644 --- a/docs/wiki/Configuration.md +++ b/docs/wiki/Configuration.md @@ -66,6 +66,7 @@ already does that. | `fixed-volume` | `--fixed-volume` | `true`/`false` — play at full volume, unmuted, and offer the server neither volume nor mute, for an output whose level is set elsewhere (a line-out into an amplifier). A remembered volume is kept but ignored, and returns once the key is dropped | `false` | | `release-device` | `--release-device` | `true`/`false` — close the output device at each stream end and reopen it at the next stream start, for an exclusive device such as an ALSA `hw:` DAC shared with other players (Squeezelite, AirPlay, Spotify). The trade-off: another app can take the device between streams, and the next stream is then discarded; each stream also pays a fresh open. ALSA and PortAudio only; other backends ignore it | `false` | | `pairing-code` | `--pairing-code` | exactly 8 digits — a static code a server can pair with, confirmed each time with `sendspin-cli pair confirm`; replaces the 6-digit code the player otherwise shows. See [Pairing a Player](Pairing-a-Player#2-static-code) | none | +| `pairing-token-endpoint` | `--pairing-token-endpoint` | `
:` — serve the pairing token over HTTP on this one internal address, e.g. `172.30.32.1:8930` on Home Assistant OS, and advertise it for local pairing; a wildcard address is refused. See [Pairing with a server on the same host](Advanced-Usage#pairing-with-a-server-on-the-same-host) | none — nothing listens | | `no-mdns` | `--no-mdns` | `true`/`false` — do not advertise `_sendspin._tcp` | `false` | | `mdns-name` | `--mdns-name` | the instance label to advertise, when it should differ from `name` | `name` | | `control-socket` | `--control-socket` | the Unix socket the subcommands talk to | `$XDG_RUNTIME_DIR/sendspin-cli-.sock` | diff --git a/src/cli.cpp b/src/cli.cpp index 55d236e..ffeaf69 100644 --- a/src/cli.cpp +++ b/src/cli.cpp @@ -20,6 +20,7 @@ #include "control.h" #include "log.h" #include "mdns.h" +#include "pairing_endpoint.h" #include "supported_formats.h" #ifdef _WIN32 @@ -80,6 +81,7 @@ enum LongOnly { OPT_PAIRING_CODE, OPT_FIXED_VOLUME, OPT_RELEASE_DEVICE, + OPT_PAIRING_TOKEN_ENDPOINT, }; /// An option a config file may set: its key (the long flag name) and how diagnostics name it. @@ -121,6 +123,7 @@ const std::vector& settable_options() { {Opt::PairingCode, "pairing-code", "--pairing-code"}, {Opt::FixedVolume, "fixed-volume", "--fixed-volume"}, {Opt::ReleaseDevice, "release-device", "--release-device"}, + {Opt::TokenEndpoint, "pairing-token-endpoint", "--pairing-token-endpoint"}, }; return table; } @@ -439,6 +442,15 @@ bool apply_option(const SettableOption& option, const std::string& value, Option } out.pairing_code = value; break; + case Opt::TokenEndpoint: { + std::string reason; + if (!parse_pairing_endpoint(value, out.pairing_endpoint_host, out.pairing_endpoint_port, + reason)) { + error = "invalid --pairing-token-endpoint '" + value + "': " + reason; + return false; + } + break; + } case Opt::NoControl: if (!parse_bool(value, out.no_control)) { error = "invalid --no-control '" + value + "' -- expected true or false"; @@ -609,6 +621,7 @@ bool parse_options(int argc, char* argv[], Options& out, std::FILE* err) { {"pairing-code", required_argument, nullptr, OPT_PAIRING_CODE}, {"fixed-volume", no_argument, nullptr, OPT_FIXED_VOLUME}, {"release-device", no_argument, nullptr, OPT_RELEASE_DEVICE}, + {"pairing-token-endpoint", required_argument, nullptr, OPT_PAIRING_TOKEN_ENDPOINT}, {nullptr, 0, nullptr, 0}, }; @@ -757,6 +770,9 @@ bool parse_options(int argc, char* argv[], Options& out, std::FILE* err) { case OPT_RELEASE_DEVICE: apply(Opt::ReleaseDevice, "true"); break; + case OPT_PAIRING_TOKEN_ENDPOINT: + apply(Opt::TokenEndpoint, optarg); + break; case OPT_MANUFACTURER: apply(Opt::Manufacturer, optarg); break; @@ -1117,6 +1133,13 @@ void print_usage(std::FILE* out, const char* prog) { std::fprintf(out, " code the player otherwise logs when a server asks to\n"); std::fprintf(out, " pair. Prefer 'pairing-code =' in the config file: a\n"); std::fprintf(out, " flag is visible to every local user in 'ps'\n"); + std::fprintf(out, " --pairing-token-endpoint
:\n"); + std::fprintf(out, " Serve this player's pairing token over HTTP on one\n"); + std::fprintf(out, " internal address, e.g. 172.30.32.1:8930 on Home\n"); + std::fprintf(out, " Assistant OS, and advertise the port so a server on\n"); + std::fprintf(out, " the same host can pair without a copied token. The\n"); + std::fprintf(out, " address is required and may not be a wildcard. Anyone\n"); + std::fprintf(out, " who can reach it can pair with this player\n"); std::fprintf(out, " --no-mdns Do not advertise over mDNS (no effect with -s, which\n"); std::fprintf(out, " already suppresses it)\n"); std::fprintf(out, " --mdns-name \n"); diff --git a/src/cli.h b/src/cli.h index 9c02687..345d5da 100644 --- a/src/cli.h +++ b/src/cli.h @@ -85,6 +85,7 @@ enum class Opt : unsigned { PairingCode, ///< --pairing-code FixedVolume, ///< --fixed-volume ReleaseDevice, ///< --release-device + TokenEndpoint, ///< --pairing-token-endpoint }; /// Everything the flag surface configures. @@ -125,6 +126,10 @@ struct Options { /// --release-device: close the output device at stream end instead of holding it. bool release_device{false}; + /// --pairing-token-endpoint
:: serve the pairing token there; port 0 is off. + std::string pairing_endpoint_host; + uint16_t pairing_endpoint_port{0}; + /// --no-mdns: do not advertise `_sendspin._tcp`. bool no_mdns{false}; diff --git a/src/control.h b/src/control.h index e391a40..44cff23 100644 --- a/src/control.h +++ b/src/control.h @@ -171,8 +171,7 @@ struct StatusSnapshot { std::string server_name; ///< `ServerInformationObject::name`, empty when not known std::string server_id; ///< `ServerInformationObject::server_id` - /// The connection's trust; absent before its handshake reports one. - std::optional trust{}; + bool paired{false}; ///< the connection is a paired session size_t paired_servers{0}; ///< servers with a stored pairing, connected or not std::string pairing_server_id; ///< the server a pairing is in progress with, else empty diff --git a/src/control_common.cpp b/src/control_common.cpp index 251dac0..3d29ad1 100644 --- a/src/control_common.cpp +++ b/src/control_common.cpp @@ -544,11 +544,8 @@ std::string format_status(const StatusSnapshot& snapshot) { if (!snapshot.connected) { append_line(out, "trust", "not connected"); - } else if (!snapshot.trust.has_value()) { - append_line(out, "trust", "unknown"); } else { - append_line(out, "trust", - *snapshot.trust == sendspin::ConnectionTrust::USER ? "paired" : "unpaired"); + append_line(out, "trust", snapshot.paired ? "paired" : "unpaired"); } append_line(out, "paired servers", std::to_string(snapshot.paired_servers)); diff --git a/src/main.cpp b/src/main.cpp index 9293e1f..1d6ca77 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -23,6 +23,7 @@ #include "log.h" #include "mdns.h" #include "outbound.h" +#include "pairing_endpoint.h" #include "player_listener.h" #include "state_store.h" #include "supported_formats.h" @@ -149,14 +150,21 @@ const char* pair_abort_reason(sendspin::SendspinPairAbortReason reason) { return "the exchange broke down"; } -/// Logs trust and pairing progress, and caches both for `status`. Main loop only. +/// Logs session and pairing progress, and caches pairing progress for `status`. Main loop only. struct PairingListener : sendspin::SendspinClientListener { - void on_trust_changed(sendspin::ConnectionTrust trust) override { - this->trust_ = trust; - // A new handshake: an attempt the last connection dropped never reported its end. - this->server_id_.clear(); - log_line(LogLevel::INFO, LOG_TAG, "Connected server is %s", - trust == sendspin::ConnectionTrust::USER ? "paired" : "not paired"); + /// One main-loop tick: logs a session that is new, or re-keyed onto a different PSK. + void tick(const sendspin::SendspinClient& client) { + // is_paired() first: a true is_connected() after it means that session is activated. + const bool paired = client.is_paired(); + const bool connected = client.is_connected(); + if (connected && (!this->connected_ || paired != this->paired_)) { + // A new handshake: an attempt the last connection dropped never reported its end. + this->server_id_.clear(); + log_line(LogLevel::INFO, LOG_TAG, "Connected server is %s", + paired ? "paired" : "not paired"); + } + this->connected_ = connected; + this->paired_ = paired; } void on_pairing_started(const std::string& server_id) override { @@ -204,11 +212,6 @@ struct PairingListener : sendspin::SendspinClientListener { log_line(LogLevel::INFO, LOG_TAG, "No longer waiting for 'pair confirm'"); } - /// The last trust reported; describes the connection only while one is up. - const std::optional& trust() const { - return this->trust_; - } - /// The server a pairing is in progress with, or empty. const std::string& server_id() const { return this->server_id_; @@ -224,7 +227,8 @@ struct PairingListener : sendspin::SendspinClientListener { } private: - std::optional trust_; + bool connected_{false}; + bool paired_{false}; std::string server_id_; std::string code_; bool window_open_{false}; @@ -495,8 +499,9 @@ class ControlDispatcher final : public ControlHandler { StatusSnapshot snapshot; snapshot.name = this->opts_.name; snapshot.client_id = this->client_.client_id(); + // Before is_connected(), so a connected snapshot's paired describes an activated session. + snapshot.paired = this->client_.is_paired(); snapshot.connected = this->client_.is_connected(); - snapshot.trust = this->pairing_listener_.trust(); snapshot.paired_servers = this->state_store_.pairing_record_count(); // A dropped connection ends its attempt without a callback. if (snapshot.connected) { @@ -831,6 +836,10 @@ int main(int argc, char* argv[]) { // The library offers one code method, and the dynamic one wins if both are set. config.static_pairing_code = opts.pairing_code; } + if (opts.pairing_endpoint_port != 0) { + config.application_roles.push_back( + {LOCAL_PAIRING_ROLE, local_pairing_support_json(opts.pairing_endpoint_port)}); + } sendspin::SendspinClient client(std::move(config)); @@ -956,11 +965,42 @@ int main(int argc, char* argv[]) { player.update_volume(volume); player.update_muted(muted); + // Bound before start(), so the port is open by the time client/hello advertises it. + std::unique_ptr pairing_endpoint; + std::string pairing_endpoint_url; + if (opts.pairing_endpoint_port != 0) { + const bool ipv6 = opts.pairing_endpoint_host.find(':') != std::string::npos; + pairing_endpoint_url = std::string("http://") + (ipv6 ? "[" : "") + + opts.pairing_endpoint_host + (ipv6 ? "]" : "") + ":" + + std::to_string(opts.pairing_endpoint_port) + PAIRING_TOKEN_PATH; + pairing_endpoint = std::make_unique(opts.pairing_endpoint_host, + opts.pairing_endpoint_port); + std::string error; + if (!pairing_endpoint->listen(error)) { + log_fatal(LOG_TAG, "--pairing-token-endpoint could not listen on %s: %s", + pairing_endpoint_url.c_str(), error.c_str()); + return 1; + } + } + if (!client.start()) { log_fatal(LOG_TAG, "could not start the Sendspin client on port %u", opts.port); return 1; } + if (pairing_endpoint) { + const std::optional token = client.pairing_token(); + if (!token.has_value()) { + log_fatal(LOG_TAG, "--pairing-token-endpoint: this player has no pairing token"); + return 1; + } + pairing_endpoint->start(*token); + log_line(LogLevel::INFO, LOG_TAG, + "Serving the pairing token on %s -- anything that can reach it can pair with " + "this player", + pairing_endpoint_url.c_str()); + } + cli_log(LogLevel::INFO, "sendspin-cli %s listening on port %u as \"%s\" (output: %s, mDNS: %s)", SENDSPIN_CLI_VERSION, opts.port, opts.name.c_str(), sink->name().c_str(), mdns_backend_name().c_str()); @@ -968,7 +1008,8 @@ int main(int argc, char* argv[]) { if (!state_store.has_pairing_record()) { const std::optional token = client.pairing_token(); // The logfile is world-readable, so the token only goes to the terminal or journal. - if (!opts.logfile.empty()) { + // Add-on logs are readable from the UI; the endpoint makes copying the token unnecessary. + if (!opts.logfile.empty() || pairing_endpoint) { log_line(LogLevel::INFO, LOG_TAG, "No server is paired yet -- run 'sendspin-cli pair-token' for the token to " "paste into a server"); @@ -1036,6 +1077,7 @@ int main(int argc, char* argv[]) { while (g_running.load()) { const int64_t now_ms = monotonic_ms(); client.loop(); + pairing_listener.tick(client); // All three need their callbacks on the main loop. mdns.poll(now_ms); control_socket.poll(now_ms, control_dispatcher); @@ -1054,6 +1096,9 @@ int main(int argc, char* argv[]) { // Before the client stops, so a restart does not race a stale record or socket. mdns.stop(); control_socket.close(); + if (pairing_endpoint) { + pairing_endpoint->stop(); + } // Returns stopped, having delivered on_stream_end() -- so the stop hook is already queued. client.stop(); // The stop hook may be pending behind a hung start hook; run it anyway. diff --git a/src/pairing_endpoint.cpp b/src/pairing_endpoint.cpp new file mode 100644 index 0000000..9a6e80f --- /dev/null +++ b/src/pairing_endpoint.cpp @@ -0,0 +1,270 @@ +// Copyright 2026 sendspin-cpp-cli Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "pairing_endpoint.h" + +#include "log.h" + +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#include +#else +#include +#include +#include +#endif + +#include +#include +#include +#include +#include +#include +#include + +namespace sendspin_cli { + +namespace { + +static constexpr const char* LOG_TAG = LOG_TAG_CLI; + +constexpr int TIMEOUT_SECS = 5; + +/// `::`, or `::ffff:0.0.0.0`, the IPv4 wildcard in mapped form. +bool is_ipv6_wildcard(const std::array& addr) { + static constexpr std::array MAPPED_ANY{0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0xff, 0xff, 0, 0, 0, 0}; + return addr == std::array{} || addr == MAPPED_ANY; +} + +bool equals_ignoring_case(const std::string& a, const std::string& b) { + return std::equal(a.begin(), a.end(), b.begin(), b.end(), [](char x, char y) { + return std::tolower(static_cast(x)) == + std::tolower(static_cast(y)); + }); +} + +/// Counts itself in `live` from its accept until its worker thread is reaped. +class CountedConnection : public ix::ConnectionState { +public: + explicit CountedConnection(std::shared_ptr> live) : live_(std::move(live)) { + ++*this->live_; + } + ~CountedConnection() override { + --*this->live_; + } + CountedConnection(const CountedConnection&) = delete; + CountedConnection& operator=(const CountedConnection&) = delete; + +private: + std::shared_ptr> live_; +}; + +} // namespace + +/// One request per connection, each on its own worker thread. +/// +/// Not ix::HttpServer: that counts only upgraded WebSocket clients against its connection limit, +/// so plain HTTP requests would each get a thread with no cap. This counts every connection. +class PairingServer final : public ix::SocketServer { +public: + using Handler = + std::function; + + PairingServer(int port, const std::string& host, int family) + : ix::SocketServer(port, host, kDefaultTcpBacklog, PAIRING_ENDPOINT_MAX_CONNECTIONS, + family) { + // The factory runs on the accept thread before the next admission check, so a burst of + // connections cannot slip past the limit before their workers start. + this->setConnectionStateFactory( + [live = this->live_] { return std::make_shared(live); }); + } + + // Joins the workers while handler_ is still alive; the base destructor would be too late. + ~PairingServer() override { + this->stop(); + } + + PairingServer(const PairingServer&) = delete; + PairingServer& operator=(const PairingServer&) = delete; + + /// Call before start(). + void set_handler(Handler handler) { + this->handler_ = std::move(handler); + } + +private: + void handleConnection(std::unique_ptr socket, + std::shared_ptr state) override { + const auto parsed = ix::Http::parseRequest(socket, TIMEOUT_SECS); + if (std::get<0>(parsed)) { + ix::Http::sendResponse(this->handler_(std::get<2>(parsed), state->getRemoteIp()), + socket); + } + state->setTerminated(); + } + + size_t getConnectedClientsCount() override { + return *this->live_; + } + + std::shared_ptr> live_ = std::make_shared>(0); + Handler handler_; +}; + +bool parse_pairing_endpoint(const std::string& value, std::string& host, uint16_t& port, + std::string& error) { + const size_t colon = value.rfind(':'); + if (colon == std::string::npos || colon == 0) { + error = "expected
: with an explicit address"; + return false; + } + std::string address = value.substr(0, colon); + const std::string port_text = value.substr(colon + 1); + + bool ipv6 = false; + if (address.front() == '[') { + if (address.size() < 3 || address.back() != ']') { + error = "an IPv6 address needs its closing ']'"; + return false; + } + address = address.substr(1, address.size() - 2); + ipv6 = true; + } else if (address.find(':') != std::string::npos) { + error = "bracket an IPv6 address, as in [fd00::1]:8930"; + return false; + } + + if (ipv6) { + std::array addr{}; + if (inet_pton(AF_INET6, address.c_str(), addr.data()) != 1) { + error = "'" + address + "' is not a numeric IPv6 address"; + return false; + } + if (is_ipv6_wildcard(addr)) { + error = "a wildcard address would listen on every interface"; + return false; + } + } else { + in_addr addr{}; + if (inet_pton(AF_INET, address.c_str(), &addr) != 1) { + error = "'" + address + "' is not a numeric IPv4 address"; + return false; + } + if (addr.s_addr == htonl(INADDR_ANY)) { + error = "a wildcard address would listen on every interface"; + return false; + } + } + + // Digits only: strtoul accepts a sign and leading spaces. + if (port_text.empty() || port_text.size() > 5 || + port_text.find_first_not_of("0123456789") != std::string::npos) { + error = "expected a port of 1-65535"; + return false; + } + const unsigned long number = std::strtoul(port_text.c_str(), nullptr, 10); + if (number == 0 || number > 65535UL) { + error = "expected a port of 1-65535"; + return false; + } + + host = address; + port = static_cast(number); + return true; +} + +std::string local_pairing_support_json(uint16_t port) { + return "{\"port\":" + std::to_string(port) + "}"; +} + +PairingReply pairing_token_reply(const std::string& method, const std::string& uri, + const std::string& host, const std::string& address, uint16_t port, + const std::string& token) { + // A client leaves the port out of Host only when it is the scheme's default. + const bool names_endpoint = equals_ignoring_case(host, address + ":" + std::to_string(port)) || + (port == 80 && equals_ignoring_case(host, address)); + if (!names_endpoint) { + return {421, "misdirected request"}; + } + const std::string path = uri.substr(0, uri.find('?')); + if (path != PAIRING_TOKEN_PATH) { + return {404, "not found"}; + } + if (method != "GET") { + return {405, "method not allowed"}; + } + return {200, token}; +} + +PairingEndpoint::PairingEndpoint(std::string host, uint16_t port) + : host_(std::move(host)), port_(port) {} + +PairingEndpoint::~PairingEndpoint() { + this->stop(); +} + +bool PairingEndpoint::listen(std::string& error) { + ix::initNetSystem(); + const int family = this->host_.find(':') != std::string::npos ? AF_INET6 : AF_INET; + this->server_ = std::make_unique(this->port_, this->host_, family); + const std::pair result = this->server_->listen(); + if (!result.first) { + error = result.second; + this->server_.reset(); + return false; + } + return true; +} + +void PairingEndpoint::start(const std::string& token) { + if (!this->server_) { + return; + } + const std::string address = + this->host_.find(':') != std::string::npos ? "[" + this->host_ + "]" : this->host_; + const uint16_t port = this->port_; + this->server_->set_handler( + [token, address, port](const ix::HttpRequestPtr& request, const std::string& remote_ip) { + const auto host = request->headers.find("Host"); + const PairingReply reply = pairing_token_reply( + request->method, request->uri, host == request->headers.end() ? "" : host->second, + address, port, token); + // The URI and Host are the caller's text, so they stay out of the log. + log_line(sendspin::LogLevel::DEBUG, LOG_TAG, + "Pairing token endpoint answered %s with %d", remote_ip.c_str(), reply.status); + ix::WebSocketHttpHeaders headers; + headers["Content-Type"] = "text/plain"; + headers["Cache-Control"] = "no-store"; + return std::make_shared(reply.status, + reply.status == 200 ? "OK" : "Error", + ix::HttpErrorCode::Ok, headers, reply.body); + }); + this->server_->start(); +} + +void PairingEndpoint::stop() { + if (this->server_) { + this->server_->stop(); + this->server_.reset(); + } +} + +} // namespace sendspin_cli diff --git a/src/pairing_endpoint.h b/src/pairing_endpoint.h new file mode 100644 index 0000000..58afbfb --- /dev/null +++ b/src/pairing_endpoint.h @@ -0,0 +1,82 @@ +// Copyright 2026 sendspin-cpp-cli Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// --pairing-token-endpoint: this player's pairing token over HTTP on one internal address. + +#pragma once + +#include +#include +#include + +namespace sendspin_cli { + +/// The application-specific role that advertises the endpoint in `client/hello`. Provisional. +inline constexpr const char* LOCAL_PAIRING_ROLE = "_ohf_local_pairing@v1"; + +/// The only path the endpoint answers. +inline constexpr const char* PAIRING_TOKEN_PATH = "/"; + +/// Connections served at once; a further one is closed as soon as it is accepted. +inline constexpr int PAIRING_ENDPOINT_MAX_CONNECTIONS = 4; + +/// Reads `
:`. The address must be a numeric IPv4 or bracketed IPv6 literal and +/// not a wildcard, so the endpoint can never listen on every interface. +/// @param host Set to the address without brackets. +/// @param error Set to a human-readable reason when the return value is false. +bool parse_pairing_endpoint(const std::string& value, std::string& host, uint16_t& port, + std::string& error); + +/// The role's support object: `{"port":N}`. +std::string local_pairing_support_json(uint16_t port); + +/// An HTTP status and plain-text body. +struct PairingReply { + int status; + std::string body; +}; + +/// The answer to one request: the token on `GET /`, an error otherwise. +/// @param host The request's Host header. It must name the endpoint itself, so a page that +/// rebinds its own hostname to the address gets no token. +/// @param address The listening address, bracketed if IPv6. +PairingReply pairing_token_reply(const std::string& method, const std::string& uri, + const std::string& host, const std::string& address, uint16_t port, + const std::string& token); + +class PairingServer; + +/// The HTTP listener; stops in its destructor. +class PairingEndpoint { +public: + PairingEndpoint(std::string host, uint16_t port); + ~PairingEndpoint(); + PairingEndpoint(const PairingEndpoint&) = delete; + PairingEndpoint& operator=(const PairingEndpoint&) = delete; + + /// Binds the address. Connections wait in the backlog until start(). + bool listen(std::string& error); + + /// Starts answering with this token. Call after listen() succeeded. + void start(const std::string& token); + + void stop(); + +private: + std::string host_; + uint16_t port_; + std::unique_ptr server_; +}; + +} // namespace sendspin_cli diff --git a/tests/cli_test.cpp b/tests/cli_test.cpp index 424238a..4a3c480 100644 --- a/tests/cli_test.cpp +++ b/tests/cli_test.cpp @@ -436,6 +436,32 @@ TEST(ParseOptions, AllowUnpairedIsOffUnlessAsked) { EXPECT_TRUE(on.options().allow_unpaired); } +TEST(ParseOptions, PairingTokenEndpointIsOffUnlessAsked) { + Parse off({}); + Parse v4({"--pairing-token-endpoint", "172.30.32.1:8930"}); + Parse v6({"--pairing-token-endpoint", "[fd00::1]:8931"}); + + ASSERT_TRUE(off.ok()) << off.diagnostics(); + ASSERT_TRUE(v4.ok()) << v4.diagnostics(); + ASSERT_TRUE(v6.ok()) << v6.diagnostics(); + EXPECT_EQ(off.options().pairing_endpoint_port, 0U); + EXPECT_EQ(v4.options().pairing_endpoint_host, "172.30.32.1"); + EXPECT_EQ(v4.options().pairing_endpoint_port, 8930U); + EXPECT_EQ(v6.options().pairing_endpoint_host, "fd00::1"); + EXPECT_EQ(v6.options().pairing_endpoint_port, 8931U); +} + +TEST(ParseOptions, PairingTokenEndpointNeedsAnExplicitAddress) { + for (const char* value : {"8930", ":8930", "0.0.0.0:8930", "[::]:8930", "[::ffff:0.0.0.0]:8930", + "localhost:8930", "fd00::1:8930", "[fd00::1:8930", "172.30.32.1:", + "172.30.32.1:0", "172.30.32.1:65536", "172.30.32.1:+80"}) { + Parse parse({"--pairing-token-endpoint", value}); + EXPECT_FALSE(parse.ok()) << "accepted --pairing-token-endpoint '" << value << "'"; + EXPECT_NE(parse.diagnostics().find("invalid --pairing-token-endpoint"), std::string::npos) + << parse.diagnostics(); + } +} + TEST(ParseOptions, FixedVolumeIsOffUnlessAsked) { Parse off({}); Parse on({"--fixed-volume"}); diff --git a/tests/config_file_test.cpp b/tests/config_file_test.cpp index ca688a7..098f727 100644 --- a/tests/config_file_test.cpp +++ b/tests/config_file_test.cpp @@ -285,6 +285,18 @@ TEST(ConfigPrecedence, TheLastDelayKeyWinsWhicheverSpellingItUses) { EXPECT_EQ(second.options().output_delay_ms, 375U); } +TEST(ConfigPrecedence, PairingTokenEndpointIsSettable) { + ScratchDir scratch; + ASSERT_TRUE(scratch.created()); + const std::string config = + scratch.write("config", "pairing-token-endpoint = 172.30.32.1:8930\n"); + + Parse from_file({}, config); + ASSERT_TRUE(from_file.ok()) << from_file.diagnostics(); + EXPECT_EQ(from_file.options().pairing_endpoint_host, "172.30.32.1"); + EXPECT_EQ(from_file.options().pairing_endpoint_port, 8930U); +} + TEST(ConfigPrecedence, AllowUnpairedIsSettableAndOffByDefault) { ScratchDir scratch; ASSERT_TRUE(scratch.created()); @@ -493,6 +505,7 @@ TEST(ConfigRefusals, ABadValueGetsTheFlagsOwnMessagePrefixedWithTheLine) { {"pairing-code = 1234567", "invalid --pairing-code -- expected exactly 8 digits"}, {"pairing-code = 12345678x", "invalid --pairing-code -- expected exactly 8 digits"}, {"pairing-code =", "invalid --pairing-code -- expected exactly 8 digits"}, + {"pairing-token-endpoint = 0.0.0.0:8930", "invalid --pairing-token-endpoint"}, {"id = kitchen-left", "--id is no longer supported"}, }) { ScratchDir scratch; diff --git a/tests/control_test.cpp b/tests/control_test.cpp index ddef18b..a7b3d90 100644 --- a/tests/control_test.cpp +++ b/tests/control_test.cpp @@ -783,18 +783,14 @@ TEST(FormatStatus, TheClientIdIsReportedEvenWhileDisconnected) { TEST(FormatStatus, TrustFollowsTheConnection) { StatusSnapshot snapshot = playing_snapshot(); - snapshot.trust = sendspin::ConnectionTrust::USER; + snapshot.paired = true; EXPECT_EQ(field(format_status(snapshot), "trust"), "paired"); - snapshot.trust = sendspin::ConnectionTrust::NONE; + snapshot.paired = false; EXPECT_EQ(field(format_status(snapshot), "trust"), "unpaired"); - // Before the handshake reports one. - snapshot.trust.reset(); - EXPECT_EQ(field(format_status(snapshot), "trust"), "unknown"); - - // A stale trust says nothing once the connection is gone. - snapshot.trust = sendspin::ConnectionTrust::USER; + // A stale session says nothing once the connection is gone. + snapshot.paired = true; snapshot.connected = false; EXPECT_EQ(field(format_status(snapshot), "trust"), "not connected"); } diff --git a/tests/pairing_endpoint_test.cpp b/tests/pairing_endpoint_test.cpp new file mode 100644 index 0000000..0102634 --- /dev/null +++ b/tests/pairing_endpoint_test.cpp @@ -0,0 +1,211 @@ +// Copyright 2026 sendspin-cpp-cli Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// The pairing token endpoint: what it answers, and that it listens only where it was told. + +#include "pairing_endpoint.h" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace sendspin_cli { +namespace { + +constexpr const char* TOKEN = "SP:EXAMPLETOKEN"; + +/// A request to 127.0.0.1:8930, with the Host header that names it. +PairingReply reply_to(const std::string& method, const std::string& uri, + const std::string& host = "127.0.0.1:8930") { + return pairing_token_reply(method, uri, host, "127.0.0.1", 8930, TOKEN); +} + +void expect_refused(const PairingReply& reply) { + EXPECT_NE(reply.status, 200); + EXPECT_EQ(reply.body.find(TOKEN), std::string::npos) << reply.body; +} + +TEST(PairingEndpoint, SupportObjectCarriesThePort) { + EXPECT_EQ(local_pairing_support_json(8930), "{\"port\":8930}"); +} + +TEST(PairingEndpoint, AnswersGetOnItsPathWithTheToken) { + const PairingReply reply = reply_to("GET", "/"); + EXPECT_EQ(reply.status, 200); + EXPECT_EQ(reply.body, TOKEN); + EXPECT_EQ(reply_to("GET", "/?x=1").status, 200); +} + +TEST(PairingEndpoint, RefusesOtherPathsAndMethodsWithoutTheToken) { + expect_refused(reply_to("GET", "/pairing-token")); + expect_refused(reply_to("GET", "//")); + expect_refused(reply_to("POST", "/")); +} + +TEST(PairingEndpoint, RefusesAHostThatDoesNotNameIt) { + // A rebound page sends its own hostname; the address alone is only right on port 80. + for (const char* host : {"evil.example:8930", "evil.example", "", "127.0.0.1:8931", "127.0.0.1", + "127.0.0.2:8930"}) { + const PairingReply reply = reply_to("GET", "/", host); + EXPECT_EQ(reply.status, 421) << host; + expect_refused(reply); + } +} + +TEST(PairingEndpoint, MatchesHostLikeAnAddress) { + EXPECT_EQ(pairing_token_reply("GET", "/", "[FD00::1]:8930", "[fd00::1]", 8930, TOKEN).status, + 200); + EXPECT_EQ(pairing_token_reply("GET", "/", "127.0.0.1", "127.0.0.1", 80, TOKEN).status, 200); + EXPECT_EQ(pairing_token_reply("GET", "/", "127.0.0.1:80", "127.0.0.1", 80, TOKEN).status, 200); +} + +/// Listens on the first free port from a fixed range; 0 when none was. +uint16_t listen_somewhere(std::unique_ptr& endpoint) { + for (uint16_t port = 38930; port < 38990; ++port) { + endpoint = std::make_unique("127.0.0.1", port); + std::string error; + if (endpoint->listen(error)) { + return port; + } + } + return 0; +} + +TEST(PairingEndpoint, ServesTheTokenOverHttp) { + std::unique_ptr endpoint; + const uint16_t port = listen_somewhere(endpoint); + ASSERT_NE(port, 0U); + endpoint->start(TOKEN); + + ix::HttpClient client; + ix::HttpRequestArgsPtr args = client.createRequest(); + args->connectTimeout = 2; + args->transferTimeout = 2; + const std::string base = "http://127.0.0.1:" + std::to_string(port); + + const ix::HttpResponsePtr ok = client.get(base + PAIRING_TOKEN_PATH, args); + ASSERT_EQ(ok->statusCode, 200) << ok->errorMsg; + EXPECT_EQ(ok->body, TOKEN); + EXPECT_EQ(ok->headers["Content-Type"], "text/plain"); + EXPECT_EQ(ok->headers["Cache-Control"], "no-store"); + + const ix::HttpResponsePtr missing = client.get(base + "/pairing-token", args); + EXPECT_EQ(missing->statusCode, 404); + endpoint->stop(); +} + +TEST(PairingEndpoint, ListensOnlyOnItsAddress) { + std::unique_ptr endpoint; + const uint16_t port = listen_somewhere(endpoint); + ASSERT_NE(port, 0U); + endpoint->start(TOKEN); + + // Another loopback address on the same port: bound to 127.0.0.1 only, so nothing answers. + ix::HttpClient client; + ix::HttpRequestArgsPtr args = client.createRequest(); + args->connectTimeout = 2; + args->transferTimeout = 2; + const ix::HttpResponsePtr other = + client.get("http://127.0.0.2:" + std::to_string(port) + PAIRING_TOKEN_PATH, args); + EXPECT_NE(other->statusCode, 200); + endpoint->stop(); +} + +/// A connection to 127.0.0.1:port that has sent nothing; null when it could not connect. +std::unique_ptr open_connection(uint16_t port) { + auto socket = std::make_unique(); + std::string error; + std::atomic cancel{false}; + if (!socket->init(error) || + !socket->connect("127.0.0.1", port, error, + ix::makeCancellationRequestWithTimeout(2, cancel))) { + return nullptr; + } + return socket; +} + +/// Sends a request on a raw connection, for headers ix::HttpClient will not send. Returns the +/// status line and the body, or "" when the connection closed before answering. +std::string raw_exchange(uint16_t port, const std::string& host) { + std::unique_ptr socket = open_connection(port); + if (!socket) { + return ""; + } + std::atomic cancel{false}; + const auto deadline = ix::makeCancellationRequestWithTimeout(2, cancel); + if (!socket->writeBytes("GET / HTTP/1.1\r\nHost: " + host + "\r\n\r\n", deadline)) { + return ""; + } + // The endpoint closes after its one response, so this reads to the end. The body has no line + // ending: readLine hands it back with false when the connection closes. + std::string response; + for (;;) { + const auto line = socket->readLine(deadline); + response += line.second; + if (!line.first) { + return response; + } + } +} + +TEST(PairingEndpoint, RefusesARebindingHostOverHttp) { + std::unique_ptr endpoint; + const uint16_t port = listen_somewhere(endpoint); + ASSERT_NE(port, 0U); + endpoint->start(TOKEN); + + const std::string response = raw_exchange(port, "evil.example:" + std::to_string(port)); + EXPECT_NE(response.find(" 421 "), std::string::npos) << response; + EXPECT_EQ(response.find(TOKEN), std::string::npos) << response; + endpoint->stop(); +} + +TEST(PairingEndpoint, ClosesAConnectionBeyondItsLimit) { + std::unique_ptr endpoint; + const uint16_t port = listen_somewhere(endpoint); + ASSERT_NE(port, 0U); + endpoint->start(TOKEN); + const std::string host = "127.0.0.1:" + std::to_string(port); + + // Stalled connections that never send a request. Accepted in order, so each is counted + // before the next connection is admitted. + std::vector> stalled; + for (int i = 0; i < PAIRING_ENDPOINT_MAX_CONNECTIONS; ++i) { + stalled.push_back(open_connection(port)); + ASSERT_NE(stalled.back(), nullptr); + } + EXPECT_EQ(raw_exchange(port, host), ""); + + // Once they go, a request is answered again, after their workers are reaped. + stalled.clear(); + std::string response; + for (int attempt = 0; attempt < 40 && response.find(TOKEN) == std::string::npos; ++attempt) { + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + response = raw_exchange(port, host); + } + EXPECT_NE(response.find(TOKEN), std::string::npos) << response; + endpoint->stop(); +} + +} // namespace +} // namespace sendspin_cli