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