Skip to content

Feature/network proxy - #169

Open
brzd-me wants to merge 18 commits into
stiefenm:mainfrom
brzd-me:feature/network-proxy
Open

brzd-me wants to merge 18 commits into
stiefenm:mainfrom
brzd-me:feature/network-proxy

Conversation

@brzd-me

@brzd-me brzd-me commented Oct 6, 2026

Copy link
Copy Markdown

Summary

Adds an optional Network proxy setting (http://host:port) that routes all SpotOn traffic to Spotify through an HTTP proxy. This covers the Web API, sign-in/token refresh, Web Player/Pathfinder, audio, and Spotify Connect. The main use case is servers in networks or regions where Spotify is not directly reachable.

With the field empty, behaviour is unchanged: the plugin builds exactly the same Slim::Networking::SimpleAsyncHTTP requests as before, and the binary is started without --proxy.

Why LMS's own "Web proxy" setting is not enough

Slim::Networking::Async::HTTP::use_proxy skips the server-wide webproxy for every https URL, and LMS has no HTTP CONNECT support. All Spotify endpoints are https, so the existing setting never applies.

Even if it did, it is server-wide: every plugin, radio stream and update check would go through the proxy. Often only Spotify needs a different route, while everything else should stay direct. A per-plugin setting covers that case.

The new setting is independent of webproxy: the plugin neither reads nor changes it.

Design

Perl side: Plugins::SpotOn::Net

  • Plugins::SpotOn::Net->http(...) replaces every direct Slim::Networking::SimpleAsyncHTTP->new(...) in the plugin. A test guards against new direct uses.
  • Without a proxy, it returns the stock SimpleAsyncHTTP.
  • With a proxy, it returns a thin subclass whose Async::HTTP subclass does the following for https:
    1. connects to the proxy;
    2. sends CONNECT host:443;
    3. starts TLS with SNI through the tunnel (Net/Socket/HTTPSConnect.pm, modelled on LMS's own HTTPSSocks);
    4. forces skipDNS, so the proxy resolves Spotify hostnames.
  • Plain http uses the standard absolute-URI proxying.
  • Requests to localhost, 127.0.0.0/8 and ::1 (the daemon, LMS itself) always go direct.
  • The handshake is blocking with one 5 s deadline, the same approach as LMS's built-in SOCKS support. After a timeout or connect failure, further attempts to that proxy fail fast for 30 s, so an unreachable proxy cannot repeatedly stall the main loop.
  • Transport errors are reported to callers with a proxy: prefix, for example proxy: CONNECT rejected: 403 Forbidden.

Binary: librespot-spoton

  • New --proxy <url> argument, applied via SessionConfig.proxy in all session modes (unified daemon, token-login, discover-once, authenticate, get-token). librespot then tunnels the AP connection, dealer websocket and its HTTP client.
  • --check reports "proxy": true.
  • Port 80 is rejected: the url crate drops the default port, and librespot's proxy socket has no default, so a port-80 proxy cannot work.

Fail-closed

  • There is no automatic fallback to a direct connection.
  • If a proxy is set and the selected binary does not report the proxy capability (for example an older custom binary), the daemon and the credential helpers are not started. A warning is shown in Settings and on the status page.
  • Setting, changing or clearing the proxy restarts running daemons, including during playback, so no session keeps using the old route.

UI

  • New field in Settings ‚Üí Global, with validation and a Test button. The button checks the typed value against apresolve.spotify.com without saving it.
  • The status page and the diagnostic bundle show a "Network: direct / via proxy" line.
  • New strings in all 11 languages.
  • TROUBLESHOOTING.md has a "Using a proxy" section and README.md has a feature bullet.

Limitations (by design for this PR)

  • HTTP CONNECT proxies only. SOCKS5 is not supported yet; most SOCKS clients (xray, sing-box, Clash) can also expose an HTTP port. The URL-with-scheme setting format leaves room to add socks5:// later.
  • No proxy authentication. librespot's CONNECT tunnel does not send Proxy-Authorization.
  • Cover images (i.scdn.co) are loaded by browsers, players and the LMS image proxy, not by the plugin, so they go direct. The existing Streaming Mode setting does not change this: it only affects how audio is relayed inside the LAN.
  • The Perl transport subclasses LMS internals and was verified against LMS 9.x sources.

Testing

  • Perl: prove t/ passes (27 files, 1269 tests) on Perl 5.36 and 5.38 (Linux containers with the same dependencies as perl-tests.yml) and on 5.34 (macOS).
    • New t/26_network_proxy.t covers URL parsing, the bypass rules, the CONNECT handshake against a forked fake proxy (200 on HTTP/1.0 and 1.1, 403/407/502, garbage, closed, silent/timeout), real TLS through the tunnel, redirects staying tunneled, the negative cache, and the guard against direct HTTP calls.
    • New t/27_proxy_daemon_restart.t covers daemon restart on proxy change and the fail-closed path.
    • t/09, t/13, t/16 and t/24 are extended.
  • Rust: cargo test --locked passes (15 tests).
  • Manual: tested on a Docker LMS (x86_64) through a real HTTP proxy. The Test button, browsing and playback all work through the proxy.

Notes for the maintainer

Thanks for SpotOn! It's a great)

  • The binary must be rebuilt, so this needs a full release, not a Perl-only one.
  • I left CHANGELOG.md untouched so you can word the entry yourself.
  • build-librespot.yml didn't run cargo test, so the existing PassthroughMixer tests and the new --proxy tests never ran in CI. I added a single test step (x86_64 job only, before the build). It only runs on release tags and manual dispatch, not on PRs. Happy to drop it if you'd rather keep CI changes separate.
  • The 10 non-English translations were not reviewed by native speakers.

sbrozdetskiy and others added 18 commits October 5, 2026 23:47
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DaemonManager::startHelper never compared the proxy, so after saving a
proxy the running daemon kept connecting to Spotify directly (and a
cleared proxy left it on the proxy). Daemon now records the proxy url it
was started with (_proxyUrl); startHelper restarts an alive daemon whose
url differs from the current setting, without the "stream active"
deferral used for cosmetic changes, and stops it when the binary cannot
take --proxy (the restart is then refused by Daemon::start).

The fail-closed refusal in Daemon::start is now logged at ERROR once per
blocked period per player instead of on every watchdog pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HTTPSConnect left io_socket_timeout at the 5 s handshake budget, and LMS
Async::write_async arms its "Timed out waiting for data" timer from it,
so proxied requests gave up after 5 s instead of the request Timeout.
Restore the caller's Timeout (30 s fallback) once the handshake is done.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tunnel handshake is blocking (up to 5 s), so a blackholed proxy
stalled the LMS main loop 5 s per API request. After a timeout or a
failed TCP connect, further tunnels to the same proxy host:port now fail
immediately for 30 s with "proxy: unreachable (recent failure, retry in
Ns)". CONNECT rejections and TLS errors are not cached (the proxy is
reachable); a successful handshake clears the entry. Requests carrying a
proxyOverride (the settings Test button) bypass the cache.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
parseProxyUrl accepted any host without whitespace, so e.g.
http://a<b:3128 was saved, rejected by the binary's Url::parse (exit 2)
and the daemon crash-looped. The host must now be hostname characters
(underscore allowed, as in Url::parse) with no empty labels, a dotted-quad
IPv4 when the last label is numeric, or a valid IPv6 literal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…omment

Guard the insecureHTTPS accessor with can() (older LMS releases may lack
it). The module comment claimed the target hostname is never resolved
locally; LMS Async::open still resolves dotless names with gethostbyname
even with skipDNS (no Spotify host is dotless) -- say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TROUBLESHOOTING: daemons restart when the saved proxy value changes (not
on every save); document "proxy: cannot connect to ...", "proxy: TLS
setup failed" and the new "proxy: unreachable (recent failure ...)";
note that the network proxy is tested with LMS 9.x. CHANGELOG: mention
restart-on-change and the fail-fast negative cache.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
waitpid on a SIGKILLed child set $? = 9 in the END block, making
t/26 exit non-zero after all tests passed on Linux (Perl 5.36/5.38).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant