feat(servers): connect and run fully behind a custom-header gate (#1273)

* fix(servers): probe gated servers over native reqwest so custom headers connect

Adding a server behind a header gate (Cloudflare Access, Pangolin service
tokens) failed at the connect step (#1216). The connect probe ran in the
WebView (axios); a non-safelisted header such as Authorization / CF-Access-*
makes the browser send a CORS preflight OPTIONS first, and that preflight
carries no token — so the gate rejects it and the real request never leaves.
Streaming and covers already worked because they go through Rust (reqwest),
which never preflights.

Route the header-bearing connect probe through a new Tauri command
`probe_server_connection` that runs the Subsonic ping over native reqwest with
the per-server header context (endpoints + apply rule), reusing the same
resolver as the data plane. Header-less servers keep the lightweight WebView
path unchanged.

- Rust: add `probe_server_connection` (+ `ServerProbeResult`) in app_api/network,
  register in collect_commands!/generate_handler!, regenerate specta bindings.
- Frontend: `pingWithCredentialsForProfile` calls the command when the profile
  has custom headers; add `serverHttpContextWireForProbe` helper.
- Tests: SubsonicClient sends the gate header on ping via http_context (and
  misses the gated matcher without it); frontend probe routing + WebView
  fallback; result mapping unit tests.

* docs(changelog): note gated-server connect fix (#1272)

* fix(servers): keep add form open and surface the reason when connect fails

The add-server flow closed the form the instant you pressed Add and, on
failure, left only a small status dot — so a bad password, a rejected gate
header, or an unreachable host all looked the same ("nothing happened").

- The connect probe now returns a short failure reason (the server's own
  error message, an HTTP status, or a transport error) via ServerProbeResult
  and the axios fallback; no secrets or header values are included.
- ServersTab keeps the Add form open until the connect test succeeds and
  shows the reason in a toast on failure (edit flow surfaces it too).
- AddServerForm validates the server address and username before probing,
  with clear per-field messages instead of a silent no-op.
- New i18n keys (serverUrlRequired, serverUsernameRequired,
  serverConnectFailedReason) added across all shipped locales.

* fix(servers): route all WebView Subsonic REST through native proxy for gated servers

Once a header-gated server (Cloudflare Access, Pangolin, …) connected, every
view that still fetched over axios in the WebView came up empty — Main stage,
New Releases, Random Albums, Statistics, search, genres — because a
non-safelisted gate header makes the WebView send a CORS preflight the gate
rejects. Only the Rust-routed paths (streaming, covers, ping) worked.

- Add a generic `subsonic_proxy_request` Tauri command backed by
  `SubsonicClient::send_raw`: it runs the request natively (no preflight),
  applies the per-server gate header via ServerHttpContext, and returns the
  untouched JSON body for the WebView to parse as it does an axios response.
  Supports GET and form-POST (OpenSubsonic formPost) with a clamped timeout.
- `api`, `apiWithCredentials`, `apiPostFormWithCredentials` (and thus
  `apiForServer`/`apiPostFormForServer`) now switch to the native proxy exactly
  when a request would carry gate headers; header-less servers keep the
  lightweight WebView axios path unchanged.
- Tests: wiremock coverage for `send_raw` (GET gate header + form-POST body);
  frontend routing tests that gated `api()` calls hit the proxy and header-less
  ones stay on axios; updated the OpenSubsonic extensions test for the new path.

* fix(servers): apply gate header to media paths via URL fallback

Streaming, prefetch and artist-info fetches returned 403 on a gated
server: apply_for_http_url resolved custom headers strictly by
server_ref and attached nothing when the caller's ref (e.g. the audio
engine's playback server id) didn't match the registry key. Add a
resolve_context helper that falls back to matching the request URL
against the server's registered endpoints — the same fallback covers
and Navidrome browse already relied on — and route the audio engine,
apply_for_http_url and apply_optional_registry_headers through it.
Endpoint matching only hits a configured gated server and still honours
the apply-to LAN/public rule, so non-gated servers stay untouched.

Also pool the native proxy's reqwest clients by timeout bucket so the
extra gated-server browse traffic reuses keep-alive connections instead
of opening a fresh pool per request (which surfaced as spurious
timeouts / 499s on fast endpoints).

* fix(servers): register gate headers before probe/bind so native paths work

Root cause of the persistent 403s behind a header gate: the native
ServerHttpRegistry was populated only at the end of bindIndexedServer,
after ensureConnectUrlResolved + the bind session. When that probe was
slow, hung, or reported the server briefly offline, the sync never ran,
leaving the registry empty — so every Rust-initiated request (stream,
cover, prefetch, fanart getArtistInfo2, Navidrome /auth/login) resolved
no header and the gate returned 403. The inline-context proxy path kept
working, which is why browse succeeded while media/covers failed.

Register the header context up front: before the reachability probe in
bindIndexedServer, and authoritatively for all servers at the start of
bootstrapAllIndexedServers (once React has mounted and IPC is ready).
Add concise sync/sync_all diagnostics (endpoint URLs + header count,
never values) so gated-server registration is observable in logs.

* fix(servers): retry gated-server covers that 403'd during header-registry gap

Covers fetched while the native gate-header registry was momentarily empty
(e.g. a dev restart before the header sync landed) got a 403, which the cover
fetcher classified as a permanent "cover missing" and wrote a 30-minute
`.fetch-failed` marker for — so on a gated server most artwork stayed blank
long after the gate started answering 200.

- Treat a gate-style 401/403 (plus 408/425/429) on cover art as a transient,
  retryable hiccup rather than a permanent miss, so the short retry loop can
  ride out a brief registry gap. Genuine 404/410/400 stay permanent.
- On (re)bind of a gated server, once its header is registered, clear that
  server's stale `.fetch-failed` markers and kick a backfill pass so the
  covers re-download — same rationale as the URL-change retry.

* refactor(servers): funnel gate-header application through one helper

Collapse the remaining bespoke header-application variants onto the single
`apply_optional_registry_headers` / `resolve_context` entry point so gated-server
header logic lives in exactly one place:

- cover_cache fetch and Navidrome `/auth/login` used older
  `apply_for_http_url` / `get_for_server_url` + `apply_server_headers_for_http_url`
  combos; both now call the shared helper.
- The audio engine's `apply_playback_request_headers` delegates to it instead of
  re-implementing the resolve+apply.
- Drop the now-unused `ServerHttpRegistry::apply_for_http_url` /
  `apply_for_base_url` methods.

Behaviour is unchanged: a non-gated server (no registry match) leaves every
request untouched, exactly as before. This is purely removing duplicate ways to
do the same thing so new native paths have one obvious hook.

* chore(servers): drop server-http registry debug logging

Remove the `[server-http] sync/sync_all` eprintln diagnostics added while
tracing the header-registry timing bug. They printed to stderr on every sync
(startup + each persist-rehydrate) and echoed endpoint URLs; the behaviour is
verified and covered by tests now, so the noise is no longer warranted. The
dev-gated `[connect-probe]`/`[subsonic-proxy]` failure logs stay.

* docs(changelog): point gated-server entry at PR #1273

* fix(servers): await gate-header sync before probe/bind

bindIndexedServer registered the per-server gate headers with a fire-and-forget
`void syncServerHttpContextForProfile(...)`, so a direct bind (add / enable a
single server) could run the native reachability probe and bind session while
the header IPC was still in flight — the registry was empty and native paths
403'd behind the gate, the exact race this branch fixes. Await the sync before
probing/binding; the stale-cover retry stays off the critical path.

* fix(servers): reclaim LAN from a sticky public endpoint

For a dual-address profile, the first endpoint that answered after launch stuck
for the whole session: a public sticky endpoint was always tried first and kept
answering, so the LAN-first order was never re-run and the connection never
upgraded back to LAN. pickReachableBaseUrl now makes a short, no-retry, bounded
quick-probe of the higher-priority LAN endpoint before honouring a public sticky
entry, so a laptop returning to the LAN upgrades on the next reachability tick
while staying remote costs only one bounded probe (never the full retry cushion).

* fix(servers): refresh LAN/public badge on active-server switch; credit #1273

The connection badge read the endpoint kind from the last probe and never
re-probed when the active server changed, so switching between a LAN-only and a
public server left the badge stuck on the old server's classification until the
120-s tick. useConnectionStatus now resets the kind and re-probes when
activeServerId changes (mount still deferred to the polling effect). Also adds
the settings credits line for the gated-server work (PR #1273).

* fix(servers): LAN reclaim uses the probe's own timeout, not a 3s race

The reclaim quick-probe raced pingWithCredentialsForProfile against a fixed 3s
timer, but the underlying probe (native reqwest for gated servers, axios
otherwise) runs on a 15s timeout — so a LAN endpoint answering between 3s and
15s was wrongly treated as unreachable and the session stayed pinned to the
public sticky endpoint. Reclaim now does a single, no-retry probe on the LAN
endpoint using the normal ping timeout: a slow-but-reachable LAN still upgrades,
while a dead one still costs only one attempt (not the full retry cushion)
before falling through to the sticky sequence.
This commit is contained in:
cucadmuh
2026-07-11 16:17:17 +03:00
committed by GitHub
parent 1625f4a8be
commit 96530f244e
38 changed files with 1275 additions and 86 deletions
@@ -578,15 +578,7 @@ pub(crate) fn apply_playback_request_headers(
url: &str,
req: reqwest::RequestBuilder,
) -> reqwest::RequestBuilder {
if let Some(reg) = registry {
if let Some(sid) = server_id.filter(|s| !s.is_empty()) {
return reg.apply_for_http_url(sid, url, req);
}
if let Some(ctx) = reg.get_for_server_url(url) {
return psysonic_core::server_http::apply_server_headers_for_http_url(req, &ctx, url);
}
}
req
psysonic_core::server_http::apply_optional_registry_headers(registry, server_id, url, req)
}
/// Custom HTTP headers for reverse-proxy gates — cloned into background download tasks.
@@ -271,32 +271,34 @@ impl ServerHttpRegistry {
None
}
pub fn apply_for_http_url(
/// Resolve a context by `server_ref` first, then fall back to matching the
/// request URL against the registered endpoints. The URL fallback is what
/// keeps gated servers working when a caller passes a stale/foreign ref
/// (e.g. the audio engine's playback server id vs. the index key): endpoint
/// matching only ever hits a registered gated server, and `apply_to` is
/// still enforced downstream, so non-gated servers are never touched.
pub fn resolve_context(
&self,
server_ref: &str,
server_ref: Option<&str>,
full_http_url: &str,
builder: RequestBuilder,
) -> RequestBuilder {
let Some(ctx) = self.get_for_server_ref(server_ref) else {
return builder;
};
apply_server_headers_for_http_url(builder, &ctx, full_http_url)
) -> Option<Arc<ServerHttpContext>> {
if let Some(sid) = server_ref.filter(|s| !s.is_empty()) {
if let Some(ctx) = self.get_for_server_ref(sid) {
return Some(ctx);
}
}
self.get_for_server_url(full_http_url)
}
pub fn apply_for_base_url(
&self,
server_ref: &str,
request_base_url: &str,
builder: RequestBuilder,
) -> RequestBuilder {
let Some(ctx) = self.get_for_server_ref(server_ref) else {
return builder;
};
apply_server_headers(builder, &ctx, request_base_url)
}
}
/// Apply custom headers when `registry` is present — prefers `server_ref`, falls back to URL match.
/// The single entry point for attaching a gated server's custom headers to any
/// native request. Resolves the context by `server_ref` first, then falls back
/// to matching the request URL against a registered gated endpoint; a non-gated
/// server (no match) leaves the builder untouched. Every raw-download call site
/// (streaming, cover art, analysis prefetch, Navidrome auth, offline transfer)
/// and `SubsonicClient::with_registry` funnel through this / `resolve_context`,
/// so gate-header behaviour lives in exactly one place.
pub fn apply_optional_registry_headers(
registry: Option<&ServerHttpRegistry>,
server_ref: Option<&str>,
@@ -304,10 +306,7 @@ pub fn apply_optional_registry_headers(
builder: RequestBuilder,
) -> RequestBuilder {
if let Some(reg) = registry {
if let Some(sid) = server_ref.filter(|s| !s.is_empty()) {
return reg.apply_for_http_url(sid, full_http_url, builder);
}
if let Some(ctx) = reg.get_for_server_url(full_http_url) {
if let Some(ctx) = reg.resolve_context(server_ref, full_http_url) {
return apply_server_headers_for_http_url(builder, &ctx, full_http_url);
}
}
@@ -343,6 +342,40 @@ mod tests {
assert_eq!(pub_.get("X-Gate").map(|v| v.to_str().ok()), Some(Some("secret")));
}
#[test]
fn resolve_context_falls_back_to_url_when_ref_is_stale() {
// Registry keyed by index key with an app-id alias; endpoint is the gate.
let reg = ServerHttpRegistry::new();
reg.sync(ServerHttpContextSyncWire {
server_id: "127.0.0.1".into(),
app_server_id: "uuid-1".into(),
endpoints: vec![ServerHttpEndpointWire {
url: "http://127.0.0.1:8899".into(),
kind: EndpointKind::Local,
}],
custom_headers: vec![CustomHeaderEntryWire {
name: "X-Gate".into(),
value: "tok".into(),
}],
custom_headers_apply_to: Some(CustomHeadersApplyTo::Both),
});
let stream_url = "http://127.0.0.1:8899/rest/stream.view?id=42&u=x&t=y";
// The audio engine passes a playback server id that is neither the index
// key nor the app-id alias — it must still resolve via the request URL.
let ctx = reg
.resolve_context(Some("some-stale-playback-id"), stream_url)
.expect("stale ref must fall back to URL endpoint match");
let headers = headers_for_request_base_url(&ctx, "http://127.0.0.1:8899");
assert_eq!(headers.get("X-Gate").map(|v| v.to_str().ok()), Some(Some("tok")));
// A non-gated server URL never resolves — foreign servers stay untouched.
assert!(reg
.resolve_context(Some("some-stale-playback-id"), "https://other.example/rest/stream.view?id=1")
.is_none());
}
#[test]
fn registry_resolves_app_id_alias() {
let reg = ServerHttpRegistry::new();
@@ -1,7 +1,7 @@
//! Auth + retry + HTTP client for Navidrome's native REST API.
//! Used by every other navidrome submodule for `/auth/*` and `/api/*` calls.
use psysonic_core::server_http::{apply_server_headers_for_http_url, apply_optional_registry_headers, ServerHttpRegistry};
use psysonic_core::server_http::{apply_optional_registry_headers, ServerHttpRegistry};
/// Authenticate with Navidrome's own REST API and return a Bearer token.
pub async fn navidrome_token(server_url: &str, username: &str, password: &str) -> Result<String, String> {
@@ -17,14 +17,14 @@ pub async fn navidrome_token_with_registry(
let client = reqwest::Client::new();
let base = server_url.trim_end_matches('/');
let login_url = format!("{base}/auth/login");
let mut req = client
.post(&login_url)
.json(&serde_json::json!({ "username": username, "password": password }));
if let Some(reg) = registry {
if let Some(ctx) = reg.get_for_server_url(server_url) {
req = apply_server_headers_for_http_url(req, &ctx, &login_url);
}
}
let req = apply_optional_registry_headers(
registry,
None,
&login_url,
client
.post(&login_url)
.json(&serde_json::json!({ "username": username, "password": password })),
);
let resp = req.send().await.map_err(|e| e.to_string())?;
let data: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
data["token"]
@@ -352,6 +352,47 @@ impl SubsonicClient {
.await
.map_err(|e| SubsonicError::Transport(flatten_reqwest_error(e)))
}
/// Raw WebView-transport bridge: the caller (TypeScript) has already built
/// the *full* query — auth params (`u`/`t`/`s`/`v`/`c`/`f`) plus the
/// endpoint's own args — so this only attaches gate headers + UA and hands
/// the untouched response body back for the frontend to parse. It lets
/// gated servers (Cloudflare Access, Pangolin, …) reach every Subsonic
/// endpoint the WebView would otherwise call over `axios`, where a
/// non-safelisted header trips a CORS preflight the gate rejects.
///
/// `endpoint` is the REST path segment *including* `.view`
/// (e.g. `getAlbumList2.view`). `post_form` sends the params as an
/// `application/x-www-form-urlencoded` body (OpenSubsonic `formPost`, for
/// large multi-`id` calls) instead of a query string.
pub async fn send_raw(
&self,
endpoint: &str,
params: &[(String, String)],
post_form: bool,
) -> Result<String, SubsonicError> {
let url = format!("{}/rest/{endpoint}", self.base_url);
let mut req = if post_form {
self.http.post(&url).form(params)
} else {
self.http.get(&url).query(params)
};
if let Some(ctx) = &self.http_context {
req = apply_server_headers(req, ctx, &self.base_url);
}
let resp = req
.send()
.await
.map_err(|e| SubsonicError::Transport(flatten_reqwest_error(e)))?;
if !resp.status().is_success() {
return Err(SubsonicError::HttpStatus(resp.status()));
}
resp.text()
.await
.map_err(|e| SubsonicError::Transport(flatten_reqwest_error(e)))
}
}
#[derive(Deserialize)]
@@ -661,6 +702,126 @@ mod tests {
test_client(&server.uri()).ping().await.expect("ping must succeed");
}
#[tokio::test(flavor = "multi_thread")]
async fn ping_sends_custom_gate_header_via_http_context() {
// Backs the connect-probe fix (#1216): a per-server gate header
// (Cloudflare Access / Pangolin) must ride on the ping itself. The mock
// only answers when the header is present, so a passing ping proves the
// header was sent on the native request (no WebView CORS preflight).
use psysonic_core::server_http::{CustomHeadersApplyTo, EndpointKind};
use wiremock::matchers::header;
let server = MockServer::start().await;
Mock::given(wm_method("GET"))
.and(wm_path("/rest/ping.view"))
.and(header("CF-Access-Client-Secret", "gate-secret"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"subsonic-response": { "status": "ok", "version": "1.16.1" }
})))
.mount(&server)
.await;
let ctx = ServerHttpContext {
endpoints: vec![(server.uri(), EndpointKind::Public)],
headers: vec![("CF-Access-Client-Secret".into(), "gate-secret".into())],
apply_to: CustomHeadersApplyTo::Public,
};
test_client(&server.uri())
.with_http_context(ctx)
.ping()
.await
.expect("ping must carry the gate header to the mounted matcher");
}
#[tokio::test(flavor = "multi_thread")]
async fn ping_without_context_misses_gate_matcher() {
// Same gated mock, but no header context: the request must NOT match, so
// the probe fails — confirming the header (not something else) is what
// unlocks the gated endpoint.
use wiremock::matchers::header;
let server = MockServer::start().await;
Mock::given(wm_method("GET"))
.and(wm_path("/rest/ping.view"))
.and(header("CF-Access-Client-Secret", "gate-secret"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"subsonic-response": { "status": "ok" }
})))
.mount(&server)
.await;
let err = test_client(&server.uri()).ping().await.unwrap_err();
assert!(
matches!(err, SubsonicError::HttpStatus(_)),
"gated endpoint without the header should not match the mock (got {err:?})"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn send_raw_get_forwards_query_and_gate_header_and_returns_body() {
// WebView-transport bridge: the frontend passes the full query (auth +
// endpoint args) and the gate header rides via the http context. The
// mock only answers when both the caller's `type` param and the gate
// header are present, and the untouched JSON body is returned verbatim.
use psysonic_core::server_http::{CustomHeadersApplyTo, EndpointKind};
use wiremock::matchers::header;
let server = MockServer::start().await;
Mock::given(wm_method("GET"))
.and(wm_path("/rest/getAlbumList2.view"))
.and(query_param("type", "newest"))
.and(query_param("u", "user"))
.and(header("CF-Access-Client-Secret", "gate-secret"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"subsonic-response": { "status": "ok", "albumList2": { "album": [] } }
})))
.mount(&server)
.await;
let ctx = ServerHttpContext {
endpoints: vec![(server.uri(), EndpointKind::Public)],
headers: vec![("CF-Access-Client-Secret".into(), "gate-secret".into())],
apply_to: CustomHeadersApplyTo::Public,
};
let params = vec![
("u".to_string(), "user".to_string()),
("type".to_string(), "newest".to_string()),
];
let body = test_client(&server.uri())
.with_http_context(ctx)
.send_raw("getAlbumList2.view", &params, false)
.await
.expect("gated raw GET must reach the mounted matcher");
assert!(body.contains("albumList2"), "raw body returned verbatim: {body}");
}
#[tokio::test(flavor = "multi_thread")]
async fn send_raw_post_form_sends_params_in_body() {
// OpenSubsonic `formPost` path for large multi-`id` calls: params ride in
// the urlencoded body, not the query string.
use wiremock::matchers::body_string_contains;
let server = MockServer::start().await;
Mock::given(wm_method("POST"))
.and(wm_path("/rest/savePlayQueue.view"))
.and(body_string_contains("id=track-1"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"subsonic-response": { "status": "ok" }
})))
.mount(&server)
.await;
let params = vec![
("u".to_string(), "user".to_string()),
("id".to_string(), "track-1".to_string()),
];
let body = test_client(&server.uri())
.send_raw("savePlayQueue.view", &params, true)
.await
.expect("form-post raw request must match the body matcher");
assert!(body.contains("\"status\": \"ok\"") || body.contains("\"status\":\"ok\""));
}
#[tokio::test(flavor = "multi_thread")]
async fn ping_surfaces_wrong_credentials_as_code_40() {
let server = MockServer::start().await;
+57 -10
View File
@@ -90,14 +90,15 @@ async fn fetch_cover_once(
registry: Option<&ServerHttpRegistry>,
server_ref: Option<&str>,
) -> FetchAttempt {
let mut req = client.get(url);
if let Some(reg) = registry {
if let Some(sid) = server_ref.filter(|s| !s.is_empty()) {
req = reg.apply_for_http_url(sid, url, req);
} else if let Some(ctx) = reg.get_for_server_url(url) {
req = psysonic_core::server_http::apply_server_headers_for_http_url(req, &ctx, url);
}
}
// Single gate-header application point — resolves by `server_ref` first,
// then falls back to matching the request URL against a registered gated
// endpoint. No match (non-gated server) leaves the request untouched.
let req = psysonic_core::server_http::apply_optional_registry_headers(
registry,
server_ref,
url,
client.get(url),
);
let resp = match req.send().await {
Ok(r) => r,
// Connection reset / timeout / DNS — transient under server load.
@@ -111,13 +112,36 @@ async fn fetch_cover_once(
};
}
let msg = format!("cover HTTP {status}");
if status.is_server_error() || status == reqwest::StatusCode::TOO_MANY_REQUESTS {
if cover_http_status_is_transient(status) {
FetchAttempt::Transient(msg)
} else {
FetchAttempt::Permanent(msg)
}
}
/// A cover HTTP status worth retrying — i.e. NOT a permanent "cover missing".
///
/// A `4xx` normally means the art is genuinely absent (`404`/`410`) or the
/// request was malformed (`400`) — permanent, so we never hammer the server for
/// it. The exceptions are gate / throttle statuses: behind a Cloudflare Access
/// or Pangolin gate a `401`/`403` means the gate refused a request whose token
/// wasn't applied (e.g. the per-server header registry hadn't been populated yet
/// at startup), which recovers the moment the header lands. Treating those as
/// transient keeps the short retry loop alive across a brief registry gap
/// instead of writing a 30-minute `.fetch-failed` marker for art that is really
/// there.
fn cover_http_status_is_transient(status: reqwest::StatusCode) -> bool {
status.is_server_error()
|| matches!(
status,
reqwest::StatusCode::UNAUTHORIZED
| reqwest::StatusCode::FORBIDDEN
| reqwest::StatusCode::REQUEST_TIMEOUT
| reqwest::StatusCode::TOO_EARLY
| reqwest::StatusCode::TOO_MANY_REQUESTS
)
}
pub async fn fetch_cover_bytes(
client: &Client,
url: &str,
@@ -145,7 +169,30 @@ pub async fn fetch_cover_bytes(
#[cfg(test)]
mod tests {
use super::build_cover_art_url;
use super::{build_cover_art_url, cover_http_status_is_transient};
use reqwest::StatusCode;
#[test]
fn gate_and_throttle_statuses_are_transient() {
// Behind an auth gate a 401/403 is a token/header hiccup that recovers
// once the per-server header lands — not a missing cover.
assert!(cover_http_status_is_transient(StatusCode::UNAUTHORIZED));
assert!(cover_http_status_is_transient(StatusCode::FORBIDDEN));
assert!(cover_http_status_is_transient(StatusCode::REQUEST_TIMEOUT));
assert!(cover_http_status_is_transient(StatusCode::TOO_EARLY));
assert!(cover_http_status_is_transient(StatusCode::TOO_MANY_REQUESTS));
assert!(cover_http_status_is_transient(StatusCode::BAD_GATEWAY));
assert!(cover_http_status_is_transient(StatusCode::SERVICE_UNAVAILABLE));
}
#[test]
fn genuine_missing_or_bad_request_is_permanent() {
// These mean the art really is absent / the request is wrong — marking
// them failed avoids hammering the server for art that isn't there.
assert!(!cover_http_status_is_transient(StatusCode::NOT_FOUND));
assert!(!cover_http_status_is_transient(StatusCode::GONE));
assert!(!cover_http_status_is_transient(StatusCode::BAD_REQUEST));
}
#[test]
fn cover_url_from_host_root() {
+4
View File
@@ -271,6 +271,8 @@ fn specta_builder() -> tauri_specta::Builder<tauri::Wry> {
crate::lib_commands::app_api::migration::migration_inspect,
crate::lib_commands::app_api::migration::migration_run,
crate::lib_commands::app_api::network::resolve_host_addresses,
crate::lib_commands::app_api::network::probe_server_connection,
crate::lib_commands::app_api::network::subsonic_proxy_request,
crate::lib_commands::app_api::network::server_http_context_clear,
crate::lib_commands::app_api::network::server_http_context_sync,
crate::lib_commands::app_api::network::server_http_context_sync_all,
@@ -932,6 +934,8 @@ pub fn run() {
migration_inspect,
migration_run,
resolve_host_addresses,
probe_server_connection,
subsonic_proxy_request,
server_http_context_sync,
server_http_context_sync_all,
server_http_context_clear,
+2 -2
View File
@@ -39,8 +39,8 @@ pub(crate) use integration::{
};
pub(crate) use migration::{migration_inspect, migration_run};
pub(crate) use network::{
resolve_host_addresses, server_http_context_clear, server_http_context_sync,
server_http_context_sync_all,
probe_server_connection, resolve_host_addresses, server_http_context_clear,
server_http_context_sync, server_http_context_sync_all, subsonic_proxy_request,
};
// Discord, Navidrome admin, last.fm + radio-browser + CORS proxy, bandsintown,
+259 -2
View File
@@ -5,10 +5,15 @@ use std::collections::HashSet;
use std::sync::Arc;
use psysonic_core::server_http::{ServerHttpContextSyncWire, ServerHttpRegistry};
use psysonic_core::server_http::{ServerHttpContext, ServerHttpContextSyncWire, ServerHttpRegistry};
use psysonic_integration::subsonic::{ServerInfo, SubsonicClient, SubsonicError};
use serde::{Deserialize, Serialize};
use tauri::State;
use tokio::net::lookup_host;
/// Connect-probe timeout — mirrors the WebView axios ping (`subsonic.ts`).
const PROBE_TIMEOUT_SECS: u64 = 15;
/// Resolve a hostname to a deduped list of IP address strings (IPv4 + IPv6).
///
/// Strips a `host:port` suffix before lookup — the form only knows the host.
@@ -95,6 +100,202 @@ pub(crate) fn server_http_context_clear(
Ok(())
}
/// Result of a connect probe — same shape the WebView `pingWithCredentials`
/// path returns (`PingWithCredentialsResult` on the TS side).
#[derive(Clone, Debug, Serialize, Deserialize, specta::Type)]
pub struct ServerProbeResult {
/// `true` when the server answered `ping` with `status="ok"`.
pub ok: bool,
/// Server software family (`navidrome`, …) when advertised.
#[serde(rename = "type")]
pub server_type: Option<String>,
/// Server build version when advertised.
#[serde(rename = "serverVersion")]
pub server_version: Option<String>,
/// Whether the server advertises OpenSubsonic extensions.
#[serde(rename = "openSubsonic")]
pub open_subsonic: bool,
/// Short human-readable reason when `ok == false` — the server's own error
/// message, an HTTP status, or a transport error — so the add/edit form can
/// tell the user *why* it couldn't connect instead of a blank failure.
/// `None` on success. Never contains header values or the password.
pub error: Option<String>,
}
impl ServerProbeResult {
fn from_info(info: ServerInfo) -> Self {
Self {
ok: true,
server_type: info.server_type,
server_version: info.server_version,
open_subsonic: info.open_subsonic,
error: None,
}
}
fn from_error(err: &SubsonicError) -> Self {
Self {
ok: false,
server_type: None,
server_version: None,
open_subsonic: false,
error: Some(probe_failure_reason(err)),
}
}
}
/// Render a compact, user-facing reason from a probe failure. Subsonic API
/// errors surface the server's own message (e.g. "Wrong username or password");
/// HTTP/transport failures surface the status or flattened transport text. No
/// secrets are ever part of a `SubsonicError`, so this is safe to show + log.
fn probe_failure_reason(err: &SubsonicError) -> String {
match err {
SubsonicError::Api { code, message } => {
let msg = message.trim();
if msg.is_empty() {
format!("server error {code}")
} else {
msg.to_string()
}
}
SubsonicError::HttpStatus(status) => match status.canonical_reason() {
Some(reason) => format!("HTTP {} {reason}", status.as_u16()),
None => format!("HTTP {}", status.as_u16()),
},
SubsonicError::Transport(m) => m.trim().to_string(),
SubsonicError::NotFound => "not found".to_string(),
SubsonicError::Decode(m) => format!("invalid response: {}", m.trim()),
}
}
fn probe_http_client() -> reqwest::Client {
reqwest::Client::builder()
.user_agent(psysonic_core::user_agent::subsonic_wire_user_agent())
.timeout(std::time::Duration::from_secs(PROBE_TIMEOUT_SECS))
.build()
.unwrap_or_else(|_| reqwest::Client::new())
}
/// Header-aware connect probe. Runs the Subsonic `ping` over the native
/// reqwest stack instead of the WebView so that per-server custom headers
/// (Cloudflare Access / Pangolin service tokens) ride on the request itself.
///
/// The WebView path can't do this behind an auth gate: a custom header like
/// `Authorization` is not CORS-safelisted, so the browser sends a preflight
/// `OPTIONS` first — and that preflight carries no token, so the gate rejects
/// it and the real request never leaves. Native reqwest never preflights, so
/// the token reaches the origin exactly as it does for streaming / sync.
///
/// `http_context` mirrors `serverHttpContextWireForProfile` for the draft
/// profile being added/edited (endpoints + headers + apply rule); pass `None`
/// for a plain probe with no custom headers. Endpoint/apply matching reuses the
/// same resolver as the data plane, so `base_url` must be the specific endpoint
/// being probed.
#[tauri::command]
#[specta::specta]
pub(crate) async fn probe_server_connection(
base_url: String,
username: String,
password: String,
http_context: Option<ServerHttpContextSyncWire>,
) -> Result<ServerProbeResult, String> {
let mut client = SubsonicClient::with_http(base_url, username, password, probe_http_client());
if let Some(wire) = http_context {
client = client.with_http_context(ServerHttpContext::from(wire));
}
match client.server_info().await {
Ok(info) => Ok(ServerProbeResult::from_info(info)),
Err(err) => {
// Unreachable / wrong credentials / non-ok envelope: surface as
// `ok:false` plus a reason the UI can show. Header values are never
// part of `err`, so this is safe to log for diagnostics — the
// gated-server case used to leave no trace at all.
crate::app_deprintln!("[connect-probe] ping failed: {err}");
Ok(ServerProbeResult::from_error(&err))
}
}
}
/// Upper bound on a proxied WebView request so a hung gate can't wedge a
/// command worker forever. The frontend passes its own per-call timeout; we
/// clamp to this ceiling.
const PROXY_MAX_TIMEOUT_SECS: u64 = 120;
/// Proxied requests reuse a small pool of `reqwest::Client`s keyed by their
/// (clamped) timeout in whole seconds. A `reqwest::Client` clone shares the
/// underlying connection pool, so pooling by timeout bucket keeps HTTP
/// keep-alive across the many concurrent browse calls a gated server routes
/// here — rather than opening (and tearing down) a fresh pool per request,
/// which under load starved connections and surfaced as spurious timeouts /
/// `499`s on otherwise-fast endpoints (`getAlbumList2`, `getAlbum`, `ping`).
fn proxy_http_client(timeout_ms: Option<u32>) -> reqwest::Client {
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
let secs = timeout_ms
.map(|ms| u64::from(ms).div_ceil(1000))
.filter(|s| *s > 0)
.unwrap_or(PROBE_TIMEOUT_SECS)
.min(PROXY_MAX_TIMEOUT_SECS);
static POOL: OnceLock<Mutex<HashMap<u64, reqwest::Client>>> = OnceLock::new();
let pool = POOL.get_or_init(|| Mutex::new(HashMap::new()));
let mut guard = pool.lock().unwrap();
guard
.entry(secs)
.or_insert_with(|| {
reqwest::Client::builder()
.user_agent(psysonic_core::user_agent::subsonic_wire_user_agent())
.timeout(std::time::Duration::from_secs(secs))
.build()
.unwrap_or_else(|_| reqwest::Client::new())
})
.clone()
}
/// WebView-transport bridge for gated servers (Cloudflare Access, Pangolin, …).
///
/// A custom gate header is not CORS-safelisted, so any Subsonic REST call the
/// WebView makes over `axios`/`fetch` triggers an `OPTIONS` preflight the gate
/// rejects — breaking browse, search, statistics, and every non-media view.
/// The frontend routes those calls here whenever it would attach a gate header;
/// this runs the request natively (no preflight) with the header applied via
/// the per-server [`ServerHttpContext`], and returns the untouched JSON body
/// for the WebView to parse exactly as it parses an `axios` response.
///
/// The frontend supplies the *full* query (auth params + endpoint args), so no
/// credentials are needed here. `endpoint` is the REST segment including
/// `.view`; `post_form` uses an `application/x-www-form-urlencoded` body.
#[tauri::command]
#[specta::specta]
pub(crate) async fn subsonic_proxy_request(
base_url: String,
endpoint: String,
params: Vec<(String, String)>,
post_form: bool,
timeout_ms: Option<u32>,
http_context: Option<ServerHttpContextSyncWire>,
) -> Result<String, String> {
let mut client = SubsonicClient::with_http(
base_url,
String::new(),
String::new(),
proxy_http_client(timeout_ms),
);
if let Some(wire) = http_context {
client = client.with_http_context(ServerHttpContext::from(wire));
}
client
.send_raw(&endpoint, &params, post_form)
.await
.map_err(|err| {
// Header values / password are never part of `err`, so logging the
// reason is safe and gives gated-server requests a diagnostic trail.
crate::app_deprintln!("[subsonic-proxy] {endpoint} failed: {err}");
probe_failure_reason(&err)
})
}
/// Strip a `:port` suffix. Handles `host:port` and `[ipv6]:port`; leaves
/// bracketed IPv6 with no port (`[::1]`) and bare hosts alone.
fn strip_port(input: &str) -> String {
@@ -120,7 +321,63 @@ fn strip_port(input: &str) -> String {
#[cfg(test)]
mod tests {
use super::strip_port;
use super::{probe_failure_reason, strip_port, ServerProbeResult};
use psysonic_integration::subsonic::{ServerInfo, SubsonicError};
#[test]
fn probe_result_from_info_carries_metadata_and_ok() {
let info = ServerInfo {
server_type: Some("navidrome".into()),
server_version: Some("0.62.0".into()),
api_version: Some("1.16.1".into()),
open_subsonic: true,
};
let r = ServerProbeResult::from_info(info);
assert!(r.ok);
assert_eq!(r.server_type.as_deref(), Some("navidrome"));
assert_eq!(r.server_version.as_deref(), Some("0.62.0"));
assert!(r.open_subsonic);
assert!(r.error.is_none());
}
#[test]
fn probe_result_from_error_is_not_ok_and_carries_reason() {
let r = ServerProbeResult::from_error(&SubsonicError::Api {
code: 40,
message: "Wrong username or password".into(),
});
assert!(!r.ok);
assert!(r.server_type.is_none());
assert!(r.server_version.is_none());
assert!(!r.open_subsonic);
assert_eq!(r.error.as_deref(), Some("Wrong username or password"));
}
#[test]
fn probe_failure_reason_prefers_server_message() {
let reason = probe_failure_reason(&SubsonicError::Api {
code: 10,
message: "missing parameter: 'u'".into(),
});
assert_eq!(reason, "missing parameter: 'u'");
}
#[test]
fn probe_failure_reason_falls_back_to_code_when_message_blank() {
let reason = probe_failure_reason(&SubsonicError::Api {
code: 40,
message: " ".into(),
});
assert_eq!(reason, "server error 40");
}
#[test]
fn probe_failure_reason_renders_http_status() {
let reason = probe_failure_reason(&SubsonicError::HttpStatus(
reqwest::StatusCode::FORBIDDEN,
));
assert_eq!(reason, "HTTP 403 Forbidden");
}
#[test]
fn strips_host_port_pair() {