Skip to content

Commit 4b034d4

Browse files
authored
Merge pull request #68 from Sendspin/chrisuthe/task/restore-direct-address-dialling-to-s-alongside
Restore direct-address dialling to -s alongside mdns: discovery
2 parents 6a81196 + b1b29d1 commit 4b034d4

18 files changed

Lines changed: 556 additions & 260 deletions

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,16 +54,16 @@ sendspin-cli -n living-room -o hw:1,0
5454
# Prefer formats, in order -- the server uses the first one it can encode
5555
sendspin-cli -n living-room --audio-format flac:48000:24:2,pcm:48000:24:2
5656

57-
# Discover a server by its advertised name and connect to it, instead of waiting to be found
58-
sendspin-cli -n living-room -s "mdns:Music Assistant"
57+
# Connect out to a specific server, instead of waiting to be found
58+
sendspin-cli -n living-room -s music.local
5959
```
6060

6161
| Option | What it does |
6262
|---|---|
6363
| `-n, --name <name>` | The friendly name a server displays. Defaults to this host's name. |
6464
| `-o, --output <device>` | Which sound card to play through. `-l` lists this host's devices and what they accept. |
6565
| `--audio-format <codec:rate:depth:channels>[,...]` | Formats to offer first, in priority order, e.g. `flac:48000:24:2,pcm:48000:24:2`. Everything else the player normally offers still follows, and a server uses the first format it can encode, so it can still fall back to a later one. This sets a preference, not a restriction. |
66-
| `-s, --server mdns:[<name>]` | Discover a server over mDNS and connect to it, rather than waiting to be discovered. `mdns:` takes any server. Turns off the mDNS advertisement. |
66+
| `-s, --server <host[:port]>` | Connect out to a server rather than waiting to be discovered, or `mdns:[<name>]` to discover one over mDNS. Turns off the mDNS advertisement. |
6767

6868
Any of these can go in a config file instead of on the command line — see
6969
[Configuration](https://github.com/Sendspin/sendspin-cpp-cli/wiki/Configuration).

docs/ROADMAP.md

Lines changed: 26 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,9 @@ daemonization.
1414
- Boots a `SendspinClient` with the `player`, `metadata` and `controller` roles, starts its
1515
WebSocket server, pumps `client.loop()`, and shuts down cleanly on `SIGINT`/`SIGTERM`.
1616
- Speaks both of the protocol's connection modes, and keeps them exclusive as the spec
17-
requires: it advertises `_sendspin._tcp` over mDNS by default, and `-s mdns:` instead
18-
discovers a server on `_sendspin-server._tcp` and dials it — retrying with a backoff
19-
until it answers (item 5). A typed-in address is no longer accepted (item 26).
17+
requires: it advertises `_sendspin._tcp` over mDNS by default, and `-s` instead dials out —
18+
either a typed-in address or a `mdns:` server it discovers on `_sendspin-server._tcp`
19+
retrying with a backoff until it answers (item 5, item 26).
2020
- Defines the `AudioSink` seam (`src/audio_sink.h`) and plays real audio through it:
2121
auto-detected ALSA (item 2), PortAudio (item 3), PulseAudio (item 18) and PipeWire
2222
(item 19) backends, with the device-less null/stdout sink as the fallback, so the
@@ -2460,71 +2460,33 @@ CLI can reach around it — so the flag cannot be expressed by any other route.
24602460
on its own. **Not expected to move soon.**
24612461

24622462

2463-
### 26. `-s` takes no address*shipped*
2463+
### 26. Direct-address dialling, removed then restored alongside `mdns:`*shipped*
24642464

2465-
`-s` used to take a typed-in address — `<host>[:<port>]` or a `ws://` URL — beside its
2466-
`mdns:` discovery form. The spec has exactly two ways to connect: the server discovers the
2465+
`-s` briefly took only its `mdns:` discovery form: a typed-in `<host>[:<port>]` or `ws://`
2466+
URL was removed because the spec has exactly two ways to connect the server discovers the
24672467
client through its `_sendspin._tcp` advertisement, or the client discovers the server on
2468-
`_sendspin-server._tcp` and connects "using the advertised address and path". Dialling a
2469-
hand-entered address is neither, so it went.
2468+
`_sendspin-server._tcp` and connects "using the advertised address and path" — and a
2469+
hand-entered address is neither.
2470+
2471+
That removal is **reversed by choice**: direct-address dialling is restored beside discovery,
2472+
because pointing a player straight at a known server is worth more here than matching the
2473+
spec's connection model exactly. This is a deliberate divergence, not a spec-conformance gap.
24702474

24712475
**Shipped** in `src/cli.{h,cpp}`, `src/main.cpp`, `src/outbound.{h,cpp}`,
24722476
`tests/cli_test.cpp`, `tests/config_file_test.cpp`, `tests/discovery_test.cpp` and
24732477
`scripts/smoke_test.sh`:
24742478

2475-
- **`-s mdns:[<name>]` is the only form**, typed or as `server =` in a config file, and it
2476-
behaves as it did: browse, pick with the last-server tie-break, dial on the retry pacer's
2477-
backoff, advertisement suppressed.
2478-
- **Anything else is a hard error at parse time**, not a warning, so an install still
2479-
configured with an address fails loudly rather than quietly changing how it connects. From a
2480-
config file the error names the file and line, and a bare `mdns` is asked whether it meant
2481-
`mdns:`.
2482-
- **The error does not quote the value.** An address was where credentials got typed, and
2483-
`redact_url_userinfo()` went with `parse_server_url()`: a discovered URL is built from a
2484-
resolved address, a port and a TXT `path`, and has no userinfo to mask. The smoke test's
2485-
credential-redaction check went with them.
2486-
- **`SENDSPIN_SERVER_URL` is answered only for the server_id the dial chose.** `LastDial` has
2487-
no "literal URL, taken at its word" case left; a dial with no id answers nothing.
2488-
2489-
### 28. A second device outage in one stream never recovers — *shipped (hardware pass still owed)*
2490-
2491-
Found by item 27's CoreAudio hardware pass — two unplugs in one run, the first back in 190 ms,
2492-
the second never — but it belongs to item 14 and sits in the shared `SinkRecovery`, so every
2493-
device-backed sink had it. **A rescan that succeeded retired the budget instead of restoring
2494-
it**: `rescan_done()` sent a recovered rescan and an exhausted one down the same branch, and
2495-
`reopen_spent_` was still set from the first outage. On the second, `reopen_due()` declined,
2496-
`escalate_()` found the rescan spent, and the sink discarded until the next track with no log
2497-
line at all.
2498-
2499-
**Shipped** in `src/sink_recovery.{h,cpp}`, its callers (`src/alsa_sink.cpp`,
2500-
`src/pulse_sink.cpp`, `src/pipewire_sink.cpp`, `src/portaudio_sink.cpp`) and
2501-
`tests/sink_recovery_test.cpp`:
2502-
2503-
- **A recovered rescan refills the budget** — the in-place reopen and the whole rescan ladder,
2504-
back to `SINK_RESCAN_DELAY_MS` — the same intent `reopen_done(true)` already had for its own
2505-
path. A recovered reopen still refills nothing, so the outage after it goes straight to the
2506-
rescan, and that rescan's recovery refills both.
2507-
- **The outage gap survives the refill.** Only the attempt bookkeeping refills; the
2508-
discarded-frame count is not `reset()`'s to drop here, since the first timed write still owes
2509-
the player that gap, and a device that dies again before one adds to it.
2510-
- **A device that never comes back still gives up.** Within one outage the budget is unchanged —
2511-
one reopen, then `SINK_RESCAN_ATTEMPTS` rescans on the doubling delay — and only a real
2512-
recovery refills it.
2513-
- **`rescan_abandoned()` for an attempt that did not recover anything.** The shutdown paths in
2514-
ALSA, PulseAudio and PipeWire used to report `rescan_done(true)` when `stop()` landed
2515-
mid-attempt, which would now refill; they abandon instead. PortAudio used to report `true` up
2516-
front whatever happened; it now abandons on each failure and reports `true` only when the
2517-
stream is really back, so its rebuild stays one-shot per outage.
2518-
2519-
**A flapping device is not given a floor of its own.** One that recovers and dies again,
2520-
repeatedly, now keeps recovering — which is what it is doing — and the loop is already paced:
2521-
each outage spends the in-place reopen at most once, the next one has to wait out
2522-
`SINK_RESCAN_DELAY_MS` before its rescan, and a refill never shortens that below the base delay.
2523-
So the main loop pays at most one rescan per two seconds whatever the device does. A cap on
2524-
recoveries per stream would turn a flaky cable back into the silence this item removes.
2525-
2526-
The regression tests (`ASecondOutageInTheSameStreamRecovers`,
2527-
`EveryOutageAfterARecoveryStillGivesUp`, `TheDiscardedGapSurvivesTheRefillIntoTheNextOutage`)
2528-
fail against the old helper and pass against the new one. **Still owed:** two unplug/replug
2529-
cycles in one stream on real hardware — ALSA first, since it has the most users, then CoreAudio
2530-
with item 27's harness.
2479+
- **`-s <address>` dials directly again**, typed or as `server =` in a config file: a bare
2480+
host (server default port 8927), `host:port`, a full `ws://`/`wss://` URL, or a bracketed
2481+
IPv6 literal. `parse_server_url()` resolves it once at parse time, so a bad address fails
2482+
before the daemon starts.
2483+
- **`-s mdns:[<name>]` still discovers**, unchanged: browse, pick with the last-server
2484+
tie-break, dial on the retry pacer's backoff. `mdns:` is reserved only before the first
2485+
colon, so `hifi:8927` is a host and a bare `-s mdns` is a host named `mdns`.
2486+
- **Any `-s` suppresses the advertisement**, address or discovery — the invariant
2487+
`advertises()` keeps, so no new code path advertises while an address is configured.
2488+
- **A parse error never quotes the credential.** An address can carry userinfo, so
2489+
`redact_url_userinfo()` masks it out of every message and every line the player logs. The
2490+
smoke test's credential-redaction check covers the dial line.
2491+
- **`SENDSPIN_SERVER_URL`** answers a discovery dial only for the `server_id` it chose, and a
2492+
literal-address dial (no id, unverifiable) at its word — `LastDial` carries both cases.

docs/wiki/Advanced-Usage.md

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,18 @@ server to connect:
1212
sendspin-cli -n living-room
1313
```
1414

15-
To make the player find a server and connect to it instead, use `-s`/`--server` with
16-
`mdns:`. The player browses `_sendspin-server._tcp` and dials what it finds. This disables
15+
To make the player connect to a known server instead, use `-s`/`--server`. This disables
1716
mDNS advertisement because the Sendspin protocol does not allow both modes at once:
1817

1918
```bash
19+
sendspin-cli --server music.local # the server port defaults to 8927
20+
sendspin-cli --server music.local:9000
21+
sendspin-cli --server ws://music.local:9000/sendspin
22+
sendspin-cli --server "[2001:db8::1]:8927" # an IPv6 literal must be bracketed
2023
sendspin-cli --server mdns: # discover any server
2124
sendspin-cli --server "mdns:Music Assistant" # ...or one by its advertised name
2225
```
2326

24-
`-s` does not take an address. The spec only has a player connect to a server it has
25-
discovered, so a host, `host:port` or `ws://` URL is refused at startup. Where mDNS cannot
26-
reach between the two, leave `-s` off and point the server at the player instead.
27-
2827
An outbound connection retries until it answers, and `--mdns-name` is unused in this
2928
mode. `--no-mdns` turns the advertisement off without switching modes.
3029

docs/wiki/Configuration.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ already does that.
5252
| `id` | `--id` | the stable client id a server files this player's settings under — two players on one host must not share it | derived from the interface MAC |
5353
| `manufacturer` | `--manufacturer` | the manufacturer `client/hello` reports | `sendspin-cpp-cli` |
5454
| `product-name` | `--product-name` | the product name `client/hello` reports | `sendspin-cli` |
55-
| `server` | `-s`, `--server` | `mdns:` to discover any server, or `mdns:<name>` for the one advertised under that name | none — wait to be discovered |
55+
| `server` | `-s`, `--server` | `<host>[:<port>]`, a `ws://` URL, or `mdns:[<name>]` | none — wait to be discovered |
5656
| `port` | `--port` | the port this player's own WebSocket server listens on | `8928` |
5757
| `buffer-ms` | `--buffer-ms` | audio the output backend keeps queued, 10–2000 | `100` |
5858
| `audio-format` | `--audio-format` | preferred formats, comma-separated in priority order: `codec:rate:depth:channels[,...]`, e.g. `flac:48000:24:2,pcm:48000:24:2`; offered first in that order, with the rest of the advertised list still behind them, so a server that cannot encode them falls back — a preference, not a restriction; refuses to start if the advertised list does not carry every one — it carries a single channel count — and an `opus` entry at anything but 48000/16 and at most 2 channels is refused outright | none — device-derived order |
@@ -77,6 +77,21 @@ Booleans take `true`/`yes`/`on`/`1` or `false`/`no`/`off`/`0`. A line whose firs
7777
character is `#` is a comment; a `#` anywhere else is not, so a name or a path is free to
7878
contain one. Where a key appears twice, the last one wins.
7979

80+
**A `server` URL that carries userinfo belongs here rather than on the command line — and it
81+
does not authenticate anything.** `ws://user:token@host:8927/sendspin` is accepted, and the
82+
`user:token` is dropped before the handshake: Sendspin authenticates in the handshake, not in
83+
the URL, so nothing turns userinfo into an `Authorization` header. If a proxy in front of
84+
your server wants HTTP Basic, this is not the way to give it to it — and the player accepting
85+
a credential it cannot send is a wrong it owes a fix, not a feature to configure around.
86+
87+
Where it still matters is what gets written down. A URL typed at `-s` is in the process's
88+
`argv`, which `ps` shows to **every** local user for as long as the player runs; in this file
89+
it is protected by the file's own permissions, so `chmod 0600` and an owner is the whole of
90+
the fix. The player also masks userinfo out of every line *it* writes — `Connecting to
91+
ws://user:***@host:8927/sendspin` — but every line tagged `sendspin.*` is the library's own
92+
and prints the URL in full, at the default log level. Treat the log of a `-s` run with
93+
userinfo in it as sensitive either way.
94+
8095
**Five things cannot come from a file**: `-l`, `-z`, `--config`, `--help` and `--version`.
8196
Run shape stays on the command line, and a config naming one is refused as an unknown key.
8297
Excluding them is reversible; debugging a `daemonize` that came out of a file under systemd

docs/wiki/Controlling-the-Player.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -211,10 +211,12 @@ with — gathered when the stream started, because a stream usually ends when it
211211
connection goes and there is nothing left to ask by then.
212212

213213
`SENDSPIN_SERVER_URL` is the one to read carefully: it is the URL this run dialled, not
214-
which server answered. A lost connection clears it, and it is exported only if the stream
215-
arrived from the discovered server it dialled: `-s` leaves the inbound listener up, and the
216-
library reports that a connection is up, not where it came from, so the server id is what
217-
tells a server that dialled *in* from the player's own dial. Read `SENDSPIN_SERVER_ID` when a hook has to be certain which server it is acting
214+
which server answered. A lost connection clears it, and when discovery chose the server it
215+
is exported only if the stream arrived from the server it dialled. On a literal `-s URL`
216+
run the rest is out of the player's reach: `-s` leaves the inbound listener up, and a
217+
server that dials *in* while that attempt is outstanding or has failed is a connection the
218+
player cannot tell from its own — the library reports that one is up, not where it came
219+
from. Read `SENDSPIN_SERVER_ID` when a hook has to be certain which server it is acting
218220
on.
219221

220222
The hook never blocks playback: it is spawned and reaped from the main loop, its output

docs/wiki/Getting-Started-on-Linux.md

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -166,16 +166,14 @@ The player advertises `_sendspin._tcp` and waits. Open your Sendspin controller
166166
should appear under the name it logged — which is `-n`, falling back to this host's name.
167167
Nothing needs configuring on the server side.
168168

169-
To go the other way and have the player discover the server and dial it instead, set
170-
`server` in the config:
169+
To go the other way and have the player dial the server instead, set `server` in the config:
171170

172171
```ini
173-
# The server advertised as "Music Assistant"; `server = mdns:` takes any
174-
server = mdns:Music Assistant
172+
# Pick one — a repeated key takes its last value, so keep a single `server` line:
173+
server = 192.168.1.10 # a host, port 8927 assumed
174+
#server = mdns:Music Assistant # or discover one by its advertised name
175175
```
176176

177-
There is no address form: the spec only has a player connect to a server it has discovered.
178-
179177
Any `server` value turns the mDNS advertisement off. That is the spec's rule rather than a
180178
preference here, and the two modes are mutually exclusive by design — see
181179
[Connection modes](Advanced-Usage#connection-modes).

docs/wiki/Home.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@ I mdns: advertising _sendspin._tcp as "living-room" on port 8928 (path /sendspin
2626
```
2727

2828
That is the whole of the usual setup: nothing to configure on either end. A Sendspin
29-
server discovers the advertisement and dials in. `-s mdns:` inverts it: this player
30-
discovers a server and dials it, which the protocol treats as the other of two mutually exclusive
29+
server discovers the advertisement and dials in. `-s <server>` inverts it and makes this
30+
player the one dialling, which the protocol treats as the other of two mutually exclusive
3131
modes — see [Connection modes](Advanced-Usage#connection-modes).
3232

3333
Audio goes out through ALSA (the Linux default) or PortAudio (the cross-platform one, and

docs/wiki/Troubleshooting.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -209,8 +209,8 @@ sudo apt install avahi-daemon libavahi-compat-libdnssd1
209209
quietly finding nothing:
210210

211211
```
212-
I mdns: This build has no mDNS support, so it can neither be discovered nor discover a
213-
server: point a server at ws://<this-host>:8928/sendspin. See docs/ROADMAP.md.
212+
I mdns: This build has no mDNS support, so it cannot be discovered: point a server at
213+
ws://<this-host>:8928/sendspin, or dial one with -s. See docs/ROADMAP.md.
214214
```
215215

216216
Rebuild with `libavahi-compat-libdnssd-dev` present, or point the server at the URL by hand.

packaging/sendspin-cli.conf.example

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@
3131
# The port this player's own WebSocket server listens on, for a server that connects in.
3232
#port = 8928
3333

34-
# Discover a server and dial it: mdns: for any, mdns:<name> for one. Disables advertising.
34+
# Dial a server instead of waiting to be found: <host>[:<port>], a ws:// URL, or mdns:<name> to
35+
# discover one by its advertised name. Any value here disables advertising, as -s does.
3536
#server = mdns:Living Room
3637

3738
# Latency added after the audio port, 0-5000 ms. A first-run default: a remembered delay wins.

scripts/smoke_test.sh

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ readonly PORT_CONTROL=39285
3232
readonly PORT_CONTROL_SECOND=39286
3333
readonly PORT_CONFIG=39287
3434
readonly PORT_DELAY=39288
35+
readonly PORT_REDACTION=39289
36+
37+
# Not a phase port: the address the redaction check dials, chosen so nothing answers it.
38+
readonly PORT_NO_SERVER=39290
3539

3640
readonly MDNS_INSTANCE="sendspin-cli-smoke"
3741

@@ -665,6 +669,38 @@ check_static_delay() {
665669
await_child "$pid" "$EXIT_TIMEOUT_S" >/dev/null 2>&1 || true
666670
}
667671

672+
# A -s URL carrying credentials is logged with them masked, and no line of ours prints them.
673+
# The library's own `sendspin.*` lines are excluded: v0.7.0+ logs the dialled URL through a bare
674+
# fprintf with no sink hook, so nothing here can redact them -- see docs/ROADMAP.md.
675+
check_credential_redaction() {
676+
local log="$WORK_DIR/redaction.log"
677+
local out="$WORK_DIR/redaction.out"
678+
# Not a real credential, and it never leaves this host: PORT_NO_SERVER answers nothing, so the
679+
# dial fails before a byte is sent. On the command line because that is the leak being tested.
680+
local secret="s3cr3t-not-a-real-password"
681+
682+
"$BIN" --no-mdns --no-control -o null --port "$PORT_REDACTION" "${NO_CONFIG[@]}" \
683+
-f "$log" -s "ws://smoke:$secret@127.0.0.1:$PORT_NO_SERVER/sendspin" >"$out" 2>&1 &
684+
local pid=$!
685+
STARTED_PIDS+=("$pid")
686+
687+
wait_for_line "$log" "Connecting to" "$BOOT_TIMEOUT_S" ||
688+
fail "no dial line within ${BOOT_TIMEOUT_S}s. Log: $(cat "$log")"
689+
690+
# -F, because the mask and the address are both regex metacharacters written literally.
691+
grep -qF "Connecting to ws://smoke:***@127.0.0.1:$PORT_NO_SERVER/sendspin" "$log" ||
692+
fail "the dial line did not mask the -s userinfo: $(grep 'Connecting to' "$log")"
693+
694+
local leaked
695+
leaked="$(grep -hv ' sendspin\.[^ :]*:' "$log" "$out" | grep -F "$secret" || true)"
696+
[ -z "$leaked" ] ||
697+
fail "a sendspin-cli log line printed the -s password: $leaked"
698+
pass "the dial line masks a -s URL's userinfo, and no line of ours written by then holds it"
699+
700+
kill -TERM "$pid" 2>/dev/null || true
701+
await_child "$pid" "$EXIT_TIMEOUT_S" >/dev/null 2>&1 || true
702+
}
703+
668704
main() {
669705
[ -x "$BIN" ] ||
670706
fail "no executable at '$BIN' -- pass the path to sendspin-cli as the first argument"
@@ -681,6 +717,7 @@ main() {
681717
check_missing_runtime_dir
682718
check_static_delay
683719
check_config_file
720+
check_credential_redaction
684721
printf 'smoke: every check passed\n'
685722
}
686723

0 commit comments

Comments
 (0)