diff --git a/README.md b/README.md index 2905434..64869b6 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,7 @@ Settings are stored in `~/.config/sendspin/`: "listen_port": 8927, "use_mpris": true, "use_hardware_volume": true, + "unpaired_access": true, "hook_set_volume": "/usr/local/bin/set-avr-volume", "manufacturer": "Acme Corp", "product_name": "Living Room Speaker", @@ -154,6 +155,7 @@ Settings are stored in `~/.config/sendspin/`: | `listen_port` | integer | daemon/serve | Listen port (`--port`, default: 8927) | | `use_mpris` | boolean | TUI/daemon | Enable MPRIS integration (default: true) | | `use_hardware_volume` | boolean | TUI/daemon | Control hardware/system output volume instead of software volume (`--hardware-volume true/false`). Default: on for daemon (if available), off for TUI | +| `unpaired_access` | boolean | TUI/daemon | Allow servers to play on this client without pairing (`--unpaired-access true/false`, default: true). See [Pairing](#pairing) | | `hook_set_volume` | string | TUI/daemon | Script to run for external volume control (`--hook-set-volume`). Receives the effective volume 0-100 as the last argument | | `hook_start` | string | TUI/daemon | Command to run when audio stream starts | | `hook_stop` | string | TUI/daemon | Command to run when audio stream stops | @@ -192,13 +194,19 @@ same server don't require pairing again. pairing completes. - **Daemon**: the PIN is only logged (`Pairing required: enter PIN ...`) — there's no other surface for it. You need to be watching the daemon's output at the moment it first - connects to a new server. Under systemd, that means `journalctl -u sendspin -f`. There's - no unattended-pairing option today (no PIN written to a file, no QR code); for a headless - install, plan to watch the log during first setup for each new server. + connects to a new server. Under systemd, that means `journalctl -u sendspin -f`. For a + headless install with unpaired access turned off, plan to watch the log during first setup + for each new server. If a pairing attempt fails (e.g. a mismatched PIN), the client logs the reason and you can retry by reconnecting. +Unpaired access is on by default, so servers can play on this client without a PIN once +the server approves it (Music Assistant does this automatically). Unpaired connections are +still encrypted, but another device on the network could impersonate either side. To +require pairing, use `--unpaired-access false` (or `"unpaired_access": false` in the +settings file). + **Upgrading from a version before pairing support:** this client's protocol identity (`client_id`) used to be a stable, often user-chosen string; it's now derived from a generated cryptographic identity instead, so upgrading a previously-configured install diff --git a/sendspin/cli.py b/sendspin/cli.py index bc06d57..7620624 100644 --- a/sendspin/cli.py +++ b/sendspin/cli.py @@ -10,6 +10,7 @@ import sys import traceback from collections.abc import Sequence +from dataclasses import replace from importlib.metadata import version from typing import TYPE_CHECKING, Any, Literal, Protocol @@ -216,6 +217,13 @@ def _add_player_runtime_options(target: ArgumentTarget, *, suppress_defaults: bo metavar="{true,false}", help="Enable or disable hardware/system volume control (daemon: on, TUI: off)", ) + target.add_argument( + "--unpaired-access", + default=default, + type=arg_str_to_bool, + metavar="{true,false}", + help="Allow servers to play on this client without pairing (default: true)", + ) target.add_argument( "--manufacturer", type=str, @@ -452,6 +460,13 @@ def _build_parser() -> argparse.ArgumentParser: metavar="{true,false}", help="Enable or disable hardware/system volume control (daemon: on, TUI: off)", ) + daemon_parser.add_argument( + "--unpaired-access", + default=None, + type=arg_str_to_bool, + metavar="{true,false}", + help="Allow servers to play on this client without pairing (default: true)", + ) daemon_parser.add_argument( "--hook-start", type=str, @@ -865,6 +880,10 @@ async def _run_client_mode(args: argparse.Namespace) -> int: args.product_name = settings.product_name if args.interface is None: args.interface = settings.interface + if args.unpaired_access is None: + args.unpaired_access = settings.unpaired_access + if not isinstance(args.unpaired_access, bool): + raise CLIError("unpaired_access in the settings file must be true or false", 1) # Set up logging: daemon uses stderr, TUI writes to sendspin.log # so log output doesn't interfere with the Rich display. @@ -878,6 +897,13 @@ async def _run_client_mode(args: argparse.Namespace) -> int: handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s")) logging.basicConfig(level=log_level, handlers=[handler]) + pairing_config = await pairing_store.get_pairing_config() + if pairing_config.unpaired_access_enabled != args.unpaired_access: + await pairing_store.store_pairing_config( + replace(pairing_config, unpaired_access_enabled=args.unpaired_access) + ) + LOGGER.info("Unpaired access %s", "enabled" if args.unpaired_access else "disabled") + audio_device = resolve_audio_device(args.audio_device) volume_controller: VolumeController | None = None diff --git a/sendspin/settings.py b/sendspin/settings.py index 0b5e9e6..582c056 100644 --- a/sendspin/settings.py +++ b/sendspin/settings.py @@ -129,6 +129,7 @@ class ClientSettings(BaseSettings): use_mpris: bool = True audio_format: str | None = None use_hardware_volume: bool | None = None + unpaired_access: bool = True hook_set_volume: str | None = None hook_start: str | None = None hook_stop: str | None = None @@ -228,6 +229,7 @@ def _load(self) -> bool: self.use_mpris = data.get("use_mpris", True) self.audio_format = data.get("audio_format") self.use_hardware_volume = data.get("use_hardware_volume") + self.unpaired_access = data.get("unpaired_access", True) self.hook_set_volume = data.get("hook_set_volume") self.hook_start = data.get("hook_start") self.hook_stop = data.get("hook_stop") diff --git a/tests/test_settings.py b/tests/test_settings.py index 4b97ad1..d18ae77 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -2,9 +2,12 @@ from __future__ import annotations +import json from pathlib import Path -from sendspin.settings import _load_identity +import pytest + +from sendspin.settings import _load_identity, get_client_settings def test_load_identity_creates_key_file_without_group_or_other_access(tmp_path: Path) -> None: @@ -20,3 +23,17 @@ def test_load_identity_creates_key_file_without_group_or_other_access(tmp_path: # The persisted key must still round-trip to the same client identity. reloaded = _load_identity(path, settings_path) assert reloaded.peer_id == identity.peer_id + + +@pytest.mark.parametrize( + ("stored", "expected"), + [({"name": "Kitchen"}, True), ({"unpaired_access": False}, False)], +) +async def test_unpaired_access_from_settings_file( + tmp_path: Path, stored: dict[str, object], expected: bool +) -> None: + (tmp_path / "settings-daemon.json").write_text(json.dumps(stored)) + + settings = await get_client_settings("daemon", str(tmp_path)) + + assert settings.unpaired_access is expected