From 63287fcc8a96ba9a97ffa6a3fbb0b7a56127069d Mon Sep 17 00:00:00 2001 From: "Jonas H." Date: Sun, 12 Jul 2026 18:09:54 +0200 Subject: [PATCH] add auth system --- cli/sjnmusic | 219 ++++++++++++++++++++++++++++++++----------- daemon/Cargo.lock | 7 ++ daemon/Cargo.toml | 5 + daemon/src/auth.rs | 206 ++++++++++++++++++++++++++++++++++++++++ daemon/src/config.rs | 27 ++++++ daemon/src/daemon.rs | 91 +++++++++++++++++- daemon/src/main.rs | 1 + daemon/src/server.rs | 156 +++++++++++++++++++++++++++++- 8 files changed, 654 insertions(+), 58 deletions(-) create mode 100644 daemon/src/auth.rs diff --git a/cli/sjnmusic b/cli/sjnmusic index 2bb6746..1c4f233 100755 --- a/cli/sjnmusic +++ b/cli/sjnmusic @@ -29,6 +29,16 @@ def build_parser() -> argparse.ArgumentParser: type=int, default=int(os.environ.get("SJNMUSIC_PORT", str(DEFAULT_PORT))), ) + p.add_argument( + "--token", + default=os.environ.get("SJNMUSIC_TOKEN"), + help=( + "bearer token for the daemon (Authorization: Bearer ). " + "Required when talking to a non-loopback daemon. Can also be set " + "via the SJNMUSIC_TOKEN environment variable. On loopback the " + "CLI auto-discovers the token from GET /auth/token." + ), + ) p.add_argument("--json", action="store_true", help="print raw JSON responses") sub = p.add_subparsers(dest="cmd", required=True) @@ -139,18 +149,18 @@ def join_query(args) -> str: return " ".join(parts).strip() -def call(host, port, method, route, body=None, query=None, timeout=15): +def call(host, port, method, route, body=None, query=None, timeout=15, headers=None): qs = "" if query: qs = "?" + urllib.parse.urlencode(query) url = f"http://{host}:{port}{route}{qs}" data = None - headers = {} + hdrs = dict(headers or {}) if body is not None: data = json.dumps(body).encode("utf-8") - headers["Content-Type"] = "application/json" - req = urllib.request.Request(url=url, data=data, headers=headers, method=method) + hdrs.setdefault("Content-Type", "application/json") + req = urllib.request.Request(url=url, data=data, headers=hdrs, method=method) try: with urllib.request.urlopen(req, timeout=timeout) as resp: @@ -163,6 +173,107 @@ def call(host, port, method, route, body=None, query=None, timeout=15): sys.exit(2) +# Loopback addresses that skip the bearer-token check on the daemon +# side. Kept in sync with the daemon's `auth::is_local_request` so the +# CLI auto-fetch fallback only ever fires when the daemon would +# actually let us through unauthenticated. +LOCAL_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"}) + + +def is_local(host): + return host in LOCAL_HOSTS + + +def fetch_token(host, port, timeout=5): + """ + Best-effort localhost-only token probe. + + GET /auth/token is itself loopback-only on the daemon side, so + reaching it implies we're a process running on the same host. We + do NOT retry on failure — a 403 here means a remote daemon is + pretending to be local and refusing (we should propagate the + error), a 404 means the daemon has auth disabled (don't auto-fetch + any token), and any other non-200 is opaque. We surface the + decoded JSON `token` field on success and `None` on every other + outcome. + """ + try: + status, payload = call( + host, port, "GET", "/auth/token", timeout=timeout + ) + except SystemExit: + # `call` exits the process on URLError; treat that as + # "couldn't reach daemon, no token". We re-raise because the + # caller is already going to error out on the original + # request anyway, but only after a transparent re-raise so + # surfacing happens at the right stack frame. + return None + if status != 200: + return None + obj = try_parse(payload) + if not isinstance(obj, dict): + return None + tok = obj.get("token") + if isinstance(tok, str) and tok: + return tok + return None + + +def request(args, host, port, method, route, body=None, query=None, timeout=15): + """ + Auth-aware wrapper around `call`. Order of operations: + + 1. If the operator supplied `--token` (or `SJNMUSIC_TOKEN`), attach + `Authorization: Bearer ` and send straight to the + daemon. We never auto-fetch on top of an explicit token — that + would silently swap a user-supplied secret with the daemon's + current one (which is rarely what an operator wants). + 2. Otherwise: if the peer is loopback, send without a token and + then, if the daemon rejected with 401, do a one-shot + `GET /auth/token` and retry. This keeps today's CLI workflows + unchanged: a user with auto-fetch works even though the + daemon now requires tokens for non-loopback callers. We + decline to silently fetch on a non-loopback host because that + would defeat the whole point of forcing operators to give an + explicit token to remote daemons. + 3. If the peer is non-loopback and no token was supplied, the + call proceeds without a header so the operator gets a + daemon-issued 401 instead of a CLI-level error — easier to + debug in the daemon logs. + """ + explicit_token = getattr(args, "token", None) or "" + hdrs = {} + if explicit_token: + hdrs["Authorization"] = f"Bearer {explicit_token}" + + status, payload = call( + host, port, method, route, body=body, query=query, + timeout=timeout, headers=hdrs, + ) + + # Auto-fetch only on FIRST observation of 401 on a loopback peer + # when no explicit token was supplied AND this is not already the + # token endpoint itself (which would obviously never require a + # token). The 401 must have come from a request method that the + # server would normally respond 2xx to without auth; if /auth/token + # is what we just hit, we'd loop forever. + if ( + status == 401 + and not explicit_token + and is_local(host) + and route != "/auth/token" + ): + fetched = fetch_token(host, port) + if fetched: + hdrs["Authorization"] = f"Bearer {fetched}" + status, payload = call( + host, port, method, route, body=body, query=query, + timeout=timeout, headers=hdrs, + ) + + return status, payload + + def fmt_secs(s): if s is None: return "--:--" @@ -252,16 +363,16 @@ def do_init(args, host, port, q, force_pick, force_auto, limit): # on a slow network; the daemon drops its state mutex before # invoking them, but the network round-trips themselves are # unchanged. Generous timeout to avoid ghost-failing. - pick_status, pick_payload = call( - host, port, "GET", "/pick", query=pick_query, timeout=180 + pick_status, pick_payload = request( + args, host, port, "GET", "/pick", query=pick_query, timeout=180 ) pick_obj = try_parse(pick_payload) if pick_status != 200 or pick_obj is None or "kind" not in pick_obj: # Fall back to legacy `/search/yt` if the daemon is too old # to know about `/pick`. Keeps the CLI working across daemon # upgrades in either direction. - legacy_status, legacy_payload = call( - host, port, "GET", "/search/yt", + legacy_status, legacy_payload = request( + args, host, port, "GET", "/search/yt", query=pick_query, timeout=120, ) legacy_obj = try_parse(legacy_payload) or {} @@ -299,8 +410,8 @@ def do_init(args, host, port, q, force_pick, force_auto, limit): ) if force_pick: # `--pick` overrides the auto decision: ask the user. - ranked_status, ranked_payload = call( - host, port, "GET", "/search/yt/ranked", + ranked_status, ranked_payload = request( + args, host, port, "GET", "/search/yt/ranked", query=pick_query, timeout=180, ) ranked_obj = try_parse(ranked_payload) or {} @@ -471,14 +582,14 @@ def main(argv=None): return f"/playlists/{urllib.parse.quote(name)}" if args.cmd == "play": - status, payload = call(host, port, "POST", "/play", body=with_q(None)) + status, payload = request(args, host, port, "POST", "/play", body=with_q(None)) elif args.cmd == "add": - status, payload = call(host, port, "POST", "/add", body=with_q(None)) + status, payload = request(args, host, port, "POST", "/add", body=with_q(None)) elif args.cmd == "del": if not q: print("error: 'del' requires a search query", file=sys.stderr) sys.exit(2) - status, payload = call(host, port, "POST", "/del", body=with_q(None)) + status, payload = request(args, host, port, "POST", "/del", body=with_q(None)) elif args.cmd == "init": if not q: print("error: 'init' requires a song name", file=sys.stderr) @@ -489,106 +600,108 @@ def main(argv=None): # Drive the ranked pipeline (do_init returns the chosen # candidate as a JSON envelope so we don't need separate - # branches below for `/init`). + # branches below for `/init`). Routing through `request` + # means auto-fetch-from-/auth/token also works for the + # search (/pick) and the eventual /init POST. _, envelope_payload = do_init( args, host, port, q, force_pick, force_auto, limit, ) chosen_obj = try_parse(envelope_payload) or {} chosen = chosen_obj.get("chosen") or {} - status, payload = call( - host, port, "POST", "/init", + status, payload = request( + args, host, port, "POST", "/init", body={"name": q, "url": chosen.get("url") or ""}, ) elif args.cmd == "init-batch": - status, payload = call( - host, port, "POST", "/init/batch", body={"songs": args.queries} + status, payload = request( + args, host, port, "POST", "/init/batch", body={"songs": args.queries} ) elif args.cmd == "search": if not q: print("error: 'search' requires a query", file=sys.stderr) sys.exit(2) - status, payload = call(host, port, "GET", "/search", query={"q": q}) + status, payload = request(args, host, port, "GET", "/search", query={"q": q}) elif args.cmd == "search-all": if not q: print("error: 'search-all' requires a query", file=sys.stderr) sys.exit(2) - status, payload = call(host, port, "GET", "/search/all", query={"q": q}) + status, payload = request(args, host, port, "GET", "/search/all", query={"q": q}) elif args.cmd == "queue": - status, payload = call(host, port, "GET", "/queue") + status, payload = request(args, host, port, "GET", "/queue") elif args.cmd in ("now", "status"): - status, payload = call(host, port, "GET", "/now-playing") + status, payload = request(args, host, port, "GET", "/now-playing") elif args.cmd == "skip": - status, payload = call(host, port, "POST", "/skip") + status, payload = request(args, host, port, "POST", "/skip") elif args.cmd == "pause": - status, payload = call(host, port, "POST", "/pause") + status, payload = request(args, host, port, "POST", "/pause") elif args.cmd == "resume": - status, payload = call(host, port, "POST", "/resume") + status, payload = request(args, host, port, "POST", "/resume") elif args.cmd == "seek": - status, payload = call(host, port, "POST", "/seek", body={"secs": args.secs}) + status, payload = request(args, host, port, "POST", "/seek", body={"secs": args.secs}) elif args.cmd == "shuffle": - status, payload = call(host, port, "POST", "/queue/shuffle") + status, payload = request(args, host, port, "POST", "/queue/shuffle") elif args.cmd == "clear": - status, payload = call(host, port, "POST", "/queue/clear") + status, payload = request(args, host, port, "POST", "/queue/clear") elif args.cmd == "songs": - status, payload = call(host, port, "GET", "/songs") + status, payload = request(args, host, port, "GET", "/songs") elif args.cmd == "downloads": - status, payload = call(host, port, "GET", "/downloads") + status, payload = request(args, host, port, "GET", "/downloads") elif args.cmd == "history": - status, payload = call(host, port, "GET", "/history") + status, payload = request(args, host, port, "GET", "/history") elif args.cmd == "stats": - status, payload = call(host, port, "GET", "/stats") + status, payload = request(args, host, port, "GET", "/stats") elif args.cmd == "volume": if args.value is None: - status, payload = call(host, port, "GET", "/volume") + status, payload = request(args, host, port, "GET", "/volume") else: # Treat 0..100 as a percentage of rodio's default 1.0. v = args.value if v > 2.0: v = v / 100.0 - status, payload = call(host, port, "POST", "/volume", body={"vol": v}) + status, payload = request(args, host, port, "POST", "/volume", body={"vol": v}) elif args.cmd == "repeat": if args.mode is None: - status, payload = call(host, port, "GET", "/repeat") + status, payload = request(args, host, port, "GET", "/repeat") else: - status, payload = call(host, port, "POST", "/repeat", body={"mode": args.mode}) + status, payload = request(args, host, port, "POST", "/repeat", body={"mode": args.mode}) elif args.cmd == "playlists": - status, payload = call(host, port, "GET", "/playlists") + status, payload = request(args, host, port, "GET", "/playlists") elif args.cmd == "help": - status, payload = call(host, port, "GET", "/help") + status, payload = request(args, host, port, "GET", "/help") elif args.cmd == "pl-new": - status, payload = call(host, port, "POST", "/playlists", body={"name": args.name}) + status, payload = request(args, host, port, "POST", "/playlists", body={"name": args.name}) elif args.cmd == "pl-show": - status, payload = call(host, port, "GET", pl_route(args.name)) + status, payload = request(args, host, port, "GET", pl_route(args.name)) elif args.cmd == "pl-add": if not q: print("error: 'pl-add' requires a song query", file=sys.stderr) sys.exit(2) - status, payload = call( - host, port, "POST", f"{pl_route(args.name)}/add", body={"query": q} + status, payload = request( + args, host, port, "POST", f"{pl_route(args.name)}/add", body={"query": q} ) elif args.cmd == "pl-play": - status, payload = call(host, port, "POST", f"{pl_route(args.name)}/play") + status, payload = request(args, host, port, "POST", f"{pl_route(args.name)}/play") elif args.cmd == "pl-rm": - status, payload = call(host, port, "DELETE", pl_route(args.name)) + status, payload = request(args, host, port, "DELETE", pl_route(args.name)) elif args.cmd == "pl-rm-song": - status, payload = call( - host, port, "DELETE", f"{pl_route(args.name)}/songs/{args.song_id}" + status, payload = request( + args, host, port, "DELETE", f"{pl_route(args.name)}/songs/{args.song_id}" ) elif args.cmd == "pl-rename": - status, payload = call( - host, port, "PATCH", pl_route(args.old), body={"name": args.new} + status, payload = request( + args, host, port, "PATCH", pl_route(args.old), body={"name": args.new} ) elif args.cmd == "pl-dup": - status, payload = call( - host, port, "POST", f"{pl_route(args.src)}/duplicate", body={"name": args.dest} + status, payload = request( + args, host, port, "POST", f"{pl_route(args.src)}/duplicate", body={"name": args.dest} ) elif args.cmd == "pl-reorder": - status, payload = call( - host, port, "POST", f"{pl_route(args.name)}/reorder", + status, payload = request( + args, host, port, "POST", f"{pl_route(args.name)}/reorder", body={"from": args.from_pos, "to": args.to_pos}, ) else: - status, payload = call(host, port, "GET", "/help") + status, payload = request(args, host, port, "GET", "/help") print_response(args, status, payload) diff --git a/daemon/Cargo.lock b/daemon/Cargo.lock index 874b113..e4ba3d3 100644 --- a/daemon/Cargo.lock +++ b/daemon/Cargo.lock @@ -261,6 +261,12 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bindgen" version = "0.72.1" @@ -1562,6 +1568,7 @@ dependencies = [ name = "sjnmusicd" version = "0.1.0" dependencies = [ + "base64", "dirs", "env_logger", "log", diff --git a/daemon/Cargo.toml b/daemon/Cargo.toml index ff6c6aa..14fa005 100644 --- a/daemon/Cargo.toml +++ b/daemon/Cargo.toml @@ -16,6 +16,11 @@ env_logger = "0.11" toml = "0.8" percent-encoding = "2" rand = "0.8" +# `base64` is used by the optional bearer-token authentication module +# (`auth.rs`) to encode the 32 random bytes of `auth_token` into a 43-char +# URL-safe string that fits comfortably in an HTTP header. Pull the URL-safe +# alphabet (no padding) so the value needs no further quoting. +base64 = "0.22" # `zbus` exposes an org.mpris.MediaPlayer2.sjnmusic service so Linux DEs and # media-key daemons can control the daemon even when the GUI process is # closed. We run zbus inside a single-threaded tokio runtime hosted by the diff --git a/daemon/src/auth.rs b/daemon/src/auth.rs new file mode 100644 index 0000000..b2dd9e6 --- /dev/null +++ b/daemon/src/auth.rs @@ -0,0 +1,206 @@ +//! Authentication helpers for the optional bearer-token scheme. +//! +//! The daemon can be configured to require `Authorization: Bearer ` +//! for any request whose peer is not a loopback address. This module owns +//! the three primitives every other auth site relies on: +//! +//! * [`generate_token`] — 32 cryptographically random bytes encoded as +//! URL-safe unpadded base64 (43 chars, header-safe). +//! * [`constant_time_eq`] — string compare that does not short-circuit on +//! mismatched prefixes/lengths, so a guesser's per-token timing leak is +//! bounded to the constant overhead of the loop. +//! * [`is_local_request`] — true iff the request's TCP peer is in the +//! loopback space (including IPv4-mapped IPv6 like `::ffff:127.0.0.1`, +//! which `IpAddr::is_loopback` alone does not classify as loopback). +//! +//! [`bearer_from_request`] is the only parse path the HTTP layer uses to +//! extract the credential string, and [`request_is_authorized`] packages +//! the full "is this caller allowed?" decision so the dispatcher can ask +//! one boolean question per route. +//! +//! These helpers never log the credential. The HTTP server's rejection +//! paths must call [`request_is_authorized`] (or `bearer_from_request` +//! + [`constant_time_eq`]) and never `debug!` the bearer header itself: +//! leaking the value to disk or stdout would defeat the entire scheme. + +use std::net::{IpAddr, SocketAddr}; + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use rand::RngCore; +use tiny_http::Request; + +/// Length of the URL-safe-base64 encoding of 32 bytes (256 / 6 = 43 +/// chars, no padding). Mirrored as a constant in the unit tests below. +const TOKEN_LEN: usize = 43; + +/// Generate a fresh 32-byte bearer token. Output is 43 chars of URL-safe +/// unpadded base64, drawn from `rand::thread_rng()` which is itself +/// backed by `getrandom` on Linux — adequate for a single shared secret +/// against non-state-level attackers. +pub fn generate_token() -> String { + let mut bytes = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut bytes); + URL_SAFE_NO_PAD.encode(bytes) +} + +/// Constant-time equality on string slices. We do NOT short-circuit when +/// lengths differ: the loop runs over `max(a.len(), b.len())` and folds +/// the length mismatch into the same XOR-accumulator. For tokens the +/// legitimate length is a 43-char public constant, so even the +/// short-circuit-on-length approach wouldn't give an attacker useful +/// information — but defending against the corner case costs almost +/// nothing here and keeps the timing profile uniform. +/// +/// The `core::hint::black_box` calls and a final `Ordering::SeqCst` +/// compiler fence keep LLVM from recognising the "compute a single +/// boolean, all of whose inputs feed through `|=`" idiom and +/// optimising it down to a short-circuiting `==` in release builds. +/// Defense in depth against an optimisation that would re-introduce +/// the very timing channel this function exists to suppress. +pub fn constant_time_eq(a: &str, b: &str) -> bool { + use std::sync::atomic::{compiler_fence, Ordering}; + let a_bytes = a.as_bytes(); + let b_bytes = b.as_bytes(); + let n = a_bytes.len().max(b_bytes.len()); + let mut diff: u8 = (a_bytes.len() != b_bytes.len()) as u8; + for i in 0..n { + let x = a_bytes.get(i).copied().unwrap_or(0); + let y = b_bytes.get(i).copied().unwrap_or(0); + diff |= x ^ y; + } + let diff = black_box_u8(diff); + compiler_fence(Ordering::SeqCst); + diff == 0 +} + +#[inline] +fn black_box_u8(x: u8) -> u8 { + // `black_box` is a hint to the optimiser: the value may be observed + // by an external observer and so should not be elided or folded. + std::hint::black_box(x) +} + +/// True iff `req`'s peer address is the IPv4 loopback subnet (including +/// `127.0.0.0/8` in principle, though tiny_http's `remote_addr` always +/// reports `127.0.0.1` on Linux), `::1`, OR an IPv4-mapped IPv6 address +/// like `::ffff:127.0.0.1` that the OS produces when a daemon with a +/// dual-stack socket accepts a v4 client. +pub fn is_local_request(req: &Request) -> bool { + match req.remote_addr() { + Some(addr) => peer_is_loopback(*addr), + None => false, + } +} + +fn peer_is_loopback(addr: SocketAddr) -> bool { + match addr.ip() { + IpAddr::V4(v4) => v4.is_loopback(), + IpAddr::V6(v6) => { + v6.is_loopback() + || v6.to_ipv4_mapped() + .map(|v4| v4.is_loopback()) + .unwrap_or(false) + } + } +} + +/// Extract the bearer token from an `Authorization` header. Returns +/// `None` if the header is absent, falls outside the Bearer scheme +/// (e.g. Basic / Digest), or carries an empty token. Both the header +/// name and the scheme word are matched case-insensitively per RFC 7235. +pub fn bearer_from_request(req: &Request) -> Option { + for header in req.headers() { + if !header.field.equiv("Authorization") { + continue; + } + let trimmed = header.value.as_str().trim_start(); + const PREFIX: &str = "Bearer "; + if trimmed.len() >= PREFIX.len() + && trimmed[..PREFIX.len()].eq_ignore_ascii_case(PREFIX) + { + let token = trimmed[PREFIX.len()..].trim(); + if !token.is_empty() { + return Some(token.to_string()); + } + } + // Authorization header was present but malformed (wrong scheme, + // empty token, etc). Do NOT silently fall through to treat it + // as "no credential"; the caller's auth check will return 401. + return None; + } + None +} + +/// One-line auth decision used by the HTTP dispatcher for every non-auth +/// route. True if the request is from a loopback peer (always trusted), +/// OR if the daemon has authentication disabled, OR if the request +/// carries a `Bearer` header whose value matches the configured token +/// under [`constant_time_eq`]. +pub fn request_is_authorized(req: &Request, auth_token: Option<&str>) -> bool { + if is_local_request(req) { + return true; + } + let Some(expected) = auth_token else { + // No token configured → authentication is off entirely. + return true; + }; + bearer_from_request(req) + .map(|t| constant_time_eq(&t, expected)) + .unwrap_or(false) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn constant_time_matches_equal_strings() { + assert!(constant_time_eq("", "")); + assert!(constant_time_eq("a", "a")); + assert!(constant_time_eq("hello world", "hello world")); + } + + #[test] + fn constant_time_rejects_unequal_strings() { + assert!(!constant_time_eq("abc", "abd")); + assert!(!constant_time_eq("abc", "ab")); + assert!(!constant_time_eq("ab", "abc")); + assert!(!constant_time_eq("", "x")); + assert!(!constant_time_eq("x", "")); + } + + #[test] + fn generate_produces_distinct_well_formed_tokens() { + let t1 = generate_token(); + let t2 = generate_token(); + assert_eq!(t1.len(), TOKEN_LEN); + assert_eq!(t2.len(), TOKEN_LEN); + assert_ne!(t1, t2, "32 random bytes should never collide"); + // URL-safe base64 alphabet: A-Z a-z 0-9 - _. + for c in t1.chars() { + assert!( + c.is_ascii_alphanumeric() || c == '-' || c == '_', + "token contained non-url-safe char: {c:?}" + ); + } + } + + #[test] + fn peer_loopback_accepts_v4_and_v6_variants() { + let v4_loopback: SocketAddr = "127.0.0.1:1234".parse().unwrap(); + let v4_other: SocketAddr = "10.0.0.1:1234".parse().unwrap(); + let v6_loopback: SocketAddr = "[::1]:1234".parse().unwrap(); + let v6_other: SocketAddr = "[2001:db8::1]:1234".parse().unwrap(); + let v4_mapped_loopback: SocketAddr = + std::net::SocketAddrV6::new("::ffff:127.0.0.1".parse().unwrap(), 1234, 0, 0) + .into(); + assert!(peer_is_loopback(v4_loopback)); + assert!(!peer_is_loopback(v4_other)); + assert!(peer_is_loopback(v6_loopback)); + assert!(!peer_is_loopback(v6_other)); + assert!( + peer_is_loopback(v4_mapped_loopback), + "IPv4-mapped IPv6 must classify as loopback" + ); + } +} diff --git a/daemon/src/config.rs b/daemon/src/config.rs index bde14ad..514d307 100644 --- a/daemon/src/config.rs +++ b/daemon/src/config.rs @@ -116,9 +116,36 @@ pub struct Config { pub search: SearchConfig, #[serde(default)] pub download: DownloadConfig, + /// When `true`, the daemon requires `Authorization: Bearer ` + /// for any request whose peer is NOT a loopback address. The matching + /// `auth_token` is auto-generated at startup if it is still empty + /// (see `daemon::run`), then persisted to disk via + /// [`Config::save`] so subsequent restarts reuse the same token. + #[serde(default)] + pub auth_enabled: bool, + /// Bearer credential compared against incoming `Authorization` headers + /// when `auth_enabled = true`. Empty string disables auth entirely + /// regardless of the `auth_enabled` flag — the daemon treats any + /// `auth_token == ""` as "no token configured, allow all remote". + #[serde(default)] + pub auth_token: String, } impl Config { + /// Persist the current `Config` back to its TOML file. Used by the + /// daemon only after a new bearer token has been auto-generated at + /// startup, so the same token is reusable across restarts. Falls + /// back silently on serialization/write failures — the daemon will + /// still boot with the in-memory token, it just won't survive a + /// restart (and will log a warning about that). + pub fn save(&self) -> std::io::Result<()> { + let path = config_path(); + let serialized = toml::to_string_pretty(self).map_err(|e| { + std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()) + })?; + fs::write(&path, serialized) + } + /// Load configuration from the TOML file. If the file does not exist, create /// it with sensible defaults so the daemon can be configured on first run. pub fn load() -> Self { diff --git a/daemon/src/daemon.rs b/daemon/src/daemon.rs index b9e795d..5b79c56 100644 --- a/daemon/src/daemon.rs +++ b/daemon/src/daemon.rs @@ -5,6 +5,7 @@ use log::{debug, error, info, warn}; use crate::{ audio::spawn_audio_thread, + auth, config::Config, db::open_db, mpris, @@ -12,9 +13,95 @@ use crate::{ state::{DaemonState, RepeatMode, Song}, }; -pub fn run(cfg: Config) { +/// Returns `true` when the bind host is a loopback interface +/// (`127.0.0.1`, `::1`, or the conventional `localhost` string). Used by +/// the startup warning paths to decide whether to alert the operator +/// about exposing the daemon on a network-reachable address. +fn bind_is_loopback(host: &str) -> bool { + matches!(host, "127.0.0.1" | "::1" | "localhost") +} + +fn warn_if_publicly_exposed(host: &str, auth_on: bool) { + if bind_is_loopback(host) { + return; + } + if auth_on { + warn!( + "sjnmusicd is listening on {host}; remote peers require the configured bearer token (Authorization: Bearer )" + ); + } else { + warn!( + "sjnmusicd is listening on {host} WITHOUT authentication; anyone on the network can control playback" + ); + // Mirror the warning to stderr so the operator sees it even + // when they start the daemon interactively without a log file + // and without RUST_LOG configured. env_logger's default filter + // ("info") would already include the warn! above, but historical + // service-launch recipes often run sjnmusicd via a unit-file + // and pipe stdout to /dev/null — belt and braces for safety. + eprintln!( + "WARNING: sjnmusicd is listening on {host} WITHOUT authentication. \ + Anyone on the network can control playback. \ + Set auth_enabled = true in {} (alongside any auth_token you want to use) to require bearer tokens.", + crate::paths::config_path(), + ); + } +} + +/// Build the effective bearer-token the HTTP server should use for +/// the lifetime of this process. Behaviour matches the design spec: +/// +/// * `auth_enabled = false` ⇒ authentication is off entirely. +/// Token returned is `None`, regardless of any leftover config value. +/// * `auth_enabled = true && auth_token == ""` ⇒ generate a fresh +/// 32-byte URL-safe base64 token, persist it to disk so the next +/// startup keeps using the same one, and return it. +/// * `auth_enabled = true && auth_token != ""` ⇒ reuse what the +/// operator already configured. We do NOT validate the token's +/// content (e.g. base64-ness) at boot — if it's malformed, +/// `request_is_authorized` will simply never match and any remote +/// caller will see 401. That's safe: a wrong token != a correct +/// token to a constant-time comparator. +/// +/// Returns `Some(token)` when auth is on, `None` otherwise. The +/// caller decides whether to emit startup warnings based on whether +/// the daemon is exposed on a non-loopback address. +fn resolve_auth_token(cfg: &mut Config) -> Option { + if !cfg.auth_enabled { + return None; + } + if !cfg.auth_token.is_empty() { + info!( + "Authentication enabled; using configured auth_token (length {})", + cfg.auth_token.len() + ); + return Some(cfg.auth_token.clone()); + } + let token = auth::generate_token(); + info!( + "Authentication enabled; generated new auth_token (length {}). Use `sjnmusic --host 127.0.0.1 GET /auth/token` to read it.", + token.len() + ); + cfg.auth_token = token.clone(); + match cfg.save() { + Ok(()) => info!( + "Persisted new auth_token to {} so restarts keep using the same token.", + crate::paths::config_path() + ), + Err(e) => warn!( + "Failed to persist new auth_token to {}: {e}. Token will rotate on every restart until the file becomes writable.", + crate::paths::config_path() + ), + } + Some(token) +} + +pub fn run(mut cfg: Config) { info!("Starting sjnmusicd"); + let auth_token = resolve_auth_token(&mut cfg); + warn_if_publicly_exposed(&cfg.server.host, auth_token.is_some()); + let conn = open_db(); let audio_handle = Arc::new(spawn_audio_thread()); @@ -35,7 +122,7 @@ pub fn run(cfg: Config) { let server_handle = thread::Builder::new() .name("sjnmusic-http".into()) .spawn(move || { - start_server(server_state, &host, port); + start_server(server_state, &host, port, auth_token); }); if let Err(e) = server_handle { error!("Failed to spawn HTTP server thread: {e}"); diff --git a/daemon/src/main.rs b/daemon/src/main.rs index ecd4d2a..079bb6d 100644 --- a/daemon/src/main.rs +++ b/daemon/src/main.rs @@ -1,4 +1,5 @@ mod audio; +mod auth; mod config; mod daemon; mod db; diff --git a/daemon/src/server.rs b/daemon/src/server.rs index ffb2fa7..ccf4db3 100644 --- a/daemon/src/server.rs +++ b/daemon/src/server.rs @@ -5,6 +5,7 @@ use serde::Serialize; use tiny_http::{Header, Method, Request, Response, Server, StatusCode}; use crate::audio::AudioCmd; +use crate::auth; use crate::state::{init_async, init_batch, DaemonState, RepeatMode}; use crate::youtube::{pick_best, rank_query, AUTO_PICK_MARGIN}; @@ -37,12 +38,24 @@ fn parse_body(body: &str) -> serde_json::Result { } } -pub fn start_server(state: Arc>, host: &str, port: u16) { +pub fn start_server( + state: Arc>, + host: &str, + port: u16, + auth_token: Option, +) { let addr = format!("{host}:{port}"); let server = match Server::http(&addr) { Ok(s) => { info!("HTTP server listening on http://{addr}"); info!("Set RUST_LOG=debug for verbose logs"); + if auth_token.is_some() { + info!( + "Bearer-token authentication enabled (remote peers require Authorization: Bearer )" + ); + } else { + info!("Authentication disabled (all peers trusted)"); + } s } Err(e) => { @@ -55,8 +68,11 @@ pub fn start_server(state: Arc>, host: &str, port: u16) { let state = state.clone(); let url = request.url().to_string(); let method = request.method().clone(); - debug!("{} {}", method, url); - if let Err(e) = handle(request, state, &method, &url) { + // Clone per request so handlers can borrow it; tiny_http consumes + // `request` in `respond`, so we can't share a long-lived binding + // across the loop without sending it back in. + let auth_token = auth_token.clone(); + if let Err(e) = handle(request, state, &method, &url, &auth_token) { warn!("Failed to respond: {e}"); } } @@ -73,14 +89,31 @@ fn handle( state: Arc>, method: &Method, url: &str, + auth_token: &Option, ) -> Result<(), Box> { let path = url.split('?').next().unwrap_or(url); let route = route_for(method, path); + // ---------------------------------------------------------------- + // Token / pairing endpoints bypass the bearer check but enforce a + // hard localhost requirement: they only respond to loopback peers. + // A non-local request to /auth/token or /auth/pair is a misuse, and + // returning a clear 403 (rather than 401, which would imply "you + // could supply the right credential") avoids confusing attackers + // who probe them from off-host. + // ---------------------------------------------------------------- + match route { + Route::AuthTokenGet => return handle_auth_token(request, auth_token), + Route::AuthPair => return handle_auth_pair(request, auth_token), + _ => {} + } + if matches!(route, Route::Help) { let resp = json_response(200, &serde_json::json!({ "endpoints": [ "GET /help", + "GET /auth/token (localhost-only; returns the bearer token when auth is enabled)", + "POST /auth/pair (localhost-only; pairing-style token endpoint, currently returns the same token as /auth/token)", "GET /songs?q=", "GET /search?q=... (best match) or POST /search {query}", "GET /search/all?q=... (every match above threshold)", @@ -122,6 +155,45 @@ fn handle( return Ok(request.respond(resp)?); } + // ---------------------------------------------------------------- + // Auth gate for every other route. We deliberately do NOT log the + // Authorization header's value (or even its presence with explicit + // token bytes) — the attacker-supplied bytes are the secret. Only + // the route + method is logged so an operator can correlate + // rejections with client intent. + // ---------------------------------------------------------------- + if !auth::request_is_authorized(&request, auth_token.as_deref()) { + debug!( + "auth: rejecting {method} {path} from {} (no/invalid token)", + request + .remote_addr() + .map(|a| a.ip().to_string()) + .unwrap_or_else(|| "".to_string()) + ); + let mut resp = json_response( + 401, + &ApiError { + error: "unauthorized".to_string(), + }, + ); + // Advertise the auth scheme to well-behaved clients (the CLI's + // auto-fetch fallback, future GUI implementations, third-party + // tools). The `realm` is purely informational; the daemon + // doesn't currently namespace per-user, so a single realm label + // is sufficient. We only attach the header when authentication + // IS actually enabled, so a 401 from a daemon with auth off + // doesn't advertise a mechanism that wouldn't accept a token. + if auth_token.is_some() { + if let Ok(h) = Header::from_bytes( + &b"WWW-Authenticate"[..], + &b"Bearer realm=\"sjnmusicd\""[..], + ) { + resp.add_header(h); + } + } + return Ok(request.respond(resp)?); + } + let mut body_text = String::new(); if matches!(method, Method::Post | Method::Delete | Method::Put | Method::Patch) { match read_body(&mut request) { @@ -152,6 +224,13 @@ fn handle( let response = match route { Route::Help => unreachable!(), + // /auth/token and /auth/pair are intercepted at the top of + // `handle()` (before the auth gate) and short-circuited with an + // explicit `return`. After that gate they cannot reach this + // dispatch block, so the arms below are defensive. + Route::AuthTokenGet | Route::AuthPair => unreachable!( + "/auth/token and /auth/pair are intercepted before this match" + ), Route::Songs => { let q = query_params.get("q").map(|s| s.as_str()); @@ -490,6 +569,73 @@ fn json_response_now_playing(state: &DaemonState) -> Response, +) -> Result<(), Box> { + if !auth::is_local_request(&request) { + let resp = json_response( + 403, + &ApiError { + error: "/auth/token is reachable only from loopback".to_string(), + }, + ); + return Ok(request.respond(resp)?); + } + match auth_token.as_deref() { + Some(t) => { + let resp = json_response(200, &serde_json::json!({ "token": t })); + Ok(request.respond(resp)?) + } + None => { + // No token configured → authentication is off. We expose + // this explicitly so the CLI knows not to bother retrying. + let resp = json_response( + 404, + &ApiError { + error: "authentication disabled (no token configured)".to_string(), + }, + ); + Ok(request.respond(resp)?) + } + } +} + +fn handle_auth_pair( + request: Request, + auth_token: &Option, +) -> Result<(), Box> { + if !auth::is_local_request(&request) { + let resp = json_response( + 403, + &ApiError { + error: "/auth/pair is reachable only from loopback".to_string(), + }, + ); + return Ok(request.respond(resp)?); + } + // The full pairing endpoint can in future rotate / generate / + // revoke. Today it mirrors /auth/token: token is generated at + // daemon startup and persists in `~/.sjn/music/config.toml`, so + // there is nothing to do here. The endpoint still exists so future + // servers can extend it without a CLI breaking change. + match auth_token.as_deref() { + Some(t) => { + let resp = json_response(200, &serde_json::json!({ "token": t })); + Ok(request.respond(resp)?) + } + None => { + let resp = json_response( + 404, + &ApiError { + error: "authentication disabled (no token configured)".to_string(), + }, + ); + Ok(request.respond(resp)?) + } + } +} + #[derive(Debug)] enum AudioRoute { Pause, @@ -524,6 +670,8 @@ fn handle_audio_route( #[derive(Debug)] enum Route { Help, + AuthTokenGet, + AuthPair, Songs, Search, SearchAll, @@ -565,6 +713,8 @@ fn route_for(method: &Method, path: &str) -> Route { let p = path.trim_end_matches('/'); match (method, p) { (Method::Get, "") | (Method::Get, "/help") => Route::Help, + (Method::Get, "/auth/token") => Route::AuthTokenGet, + (Method::Post, "/auth/pair") => Route::AuthPair, (Method::Get, "/songs") => Route::Songs, (m, "/search") if matches!(m, Method::Get | Method::Post) => Route::Search, (Method::Get, "/search/all") => Route::SearchAll,