add auth system

This commit is contained in:
2026-07-12 18:10:17 +02:00
parent 93be6a7f68
commit 63287fcc8a
8 changed files with 654 additions and 58 deletions
+166 -53
View File
@@ -29,6 +29,16 @@ def build_parser() -> argparse.ArgumentParser:
type=int, type=int,
default=int(os.environ.get("SJNMUSIC_PORT", str(DEFAULT_PORT))), 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 <token>). "
"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") p.add_argument("--json", action="store_true", help="print raw JSON responses")
sub = p.add_subparsers(dest="cmd", required=True) sub = p.add_subparsers(dest="cmd", required=True)
@@ -139,18 +149,18 @@ def join_query(args) -> str:
return " ".join(parts).strip() 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 = "" qs = ""
if query: if query:
qs = "?" + urllib.parse.urlencode(query) qs = "?" + urllib.parse.urlencode(query)
url = f"http://{host}:{port}{route}{qs}" url = f"http://{host}:{port}{route}{qs}"
data = None data = None
headers = {} hdrs = dict(headers or {})
if body is not None: if body is not None:
data = json.dumps(body).encode("utf-8") data = json.dumps(body).encode("utf-8")
headers["Content-Type"] = "application/json" hdrs.setdefault("Content-Type", "application/json")
req = urllib.request.Request(url=url, data=data, headers=headers, method=method) req = urllib.request.Request(url=url, data=data, headers=hdrs, method=method)
try: try:
with urllib.request.urlopen(req, timeout=timeout) as resp: 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) 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 <token>` 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): def fmt_secs(s):
if s is None: if s is None:
return "--:--" 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 # on a slow network; the daemon drops its state mutex before
# invoking them, but the network round-trips themselves are # invoking them, but the network round-trips themselves are
# unchanged. Generous timeout to avoid ghost-failing. # unchanged. Generous timeout to avoid ghost-failing.
pick_status, pick_payload = call( pick_status, pick_payload = request(
host, port, "GET", "/pick", query=pick_query, timeout=180 args, host, port, "GET", "/pick", query=pick_query, timeout=180
) )
pick_obj = try_parse(pick_payload) pick_obj = try_parse(pick_payload)
if pick_status != 200 or pick_obj is None or "kind" not in pick_obj: 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 # Fall back to legacy `/search/yt` if the daemon is too old
# to know about `/pick`. Keeps the CLI working across daemon # to know about `/pick`. Keeps the CLI working across daemon
# upgrades in either direction. # upgrades in either direction.
legacy_status, legacy_payload = call( legacy_status, legacy_payload = request(
host, port, "GET", "/search/yt", args, host, port, "GET", "/search/yt",
query=pick_query, timeout=120, query=pick_query, timeout=120,
) )
legacy_obj = try_parse(legacy_payload) or {} 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: if force_pick:
# `--pick` overrides the auto decision: ask the user. # `--pick` overrides the auto decision: ask the user.
ranked_status, ranked_payload = call( ranked_status, ranked_payload = request(
host, port, "GET", "/search/yt/ranked", args, host, port, "GET", "/search/yt/ranked",
query=pick_query, timeout=180, query=pick_query, timeout=180,
) )
ranked_obj = try_parse(ranked_payload) or {} ranked_obj = try_parse(ranked_payload) or {}
@@ -471,14 +582,14 @@ def main(argv=None):
return f"/playlists/{urllib.parse.quote(name)}" return f"/playlists/{urllib.parse.quote(name)}"
if args.cmd == "play": 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": 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": elif args.cmd == "del":
if not q: if not q:
print("error: 'del' requires a search query", file=sys.stderr) print("error: 'del' requires a search query", file=sys.stderr)
sys.exit(2) 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": elif args.cmd == "init":
if not q: if not q:
print("error: 'init' requires a song name", file=sys.stderr) 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 # Drive the ranked pipeline (do_init returns the chosen
# candidate as a JSON envelope so we don't need separate # 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( _, envelope_payload = do_init(
args, host, port, q, force_pick, force_auto, limit, args, host, port, q, force_pick, force_auto, limit,
) )
chosen_obj = try_parse(envelope_payload) or {} chosen_obj = try_parse(envelope_payload) or {}
chosen = chosen_obj.get("chosen") or {} chosen = chosen_obj.get("chosen") or {}
status, payload = call( status, payload = request(
host, port, "POST", "/init", args, host, port, "POST", "/init",
body={"name": q, "url": chosen.get("url") or ""}, body={"name": q, "url": chosen.get("url") or ""},
) )
elif args.cmd == "init-batch": elif args.cmd == "init-batch":
status, payload = call( status, payload = request(
host, port, "POST", "/init/batch", body={"songs": args.queries} args, host, port, "POST", "/init/batch", body={"songs": args.queries}
) )
elif args.cmd == "search": elif args.cmd == "search":
if not q: if not q:
print("error: 'search' requires a query", file=sys.stderr) print("error: 'search' requires a query", file=sys.stderr)
sys.exit(2) 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": elif args.cmd == "search-all":
if not q: if not q:
print("error: 'search-all' requires a query", file=sys.stderr) print("error: 'search-all' requires a query", file=sys.stderr)
sys.exit(2) 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": 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"): 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": elif args.cmd == "skip":
status, payload = call(host, port, "POST", "/skip") status, payload = request(args, host, port, "POST", "/skip")
elif args.cmd == "pause": elif args.cmd == "pause":
status, payload = call(host, port, "POST", "/pause") status, payload = request(args, host, port, "POST", "/pause")
elif args.cmd == "resume": elif args.cmd == "resume":
status, payload = call(host, port, "POST", "/resume") status, payload = request(args, host, port, "POST", "/resume")
elif args.cmd == "seek": 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": 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": 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": elif args.cmd == "songs":
status, payload = call(host, port, "GET", "/songs") status, payload = request(args, host, port, "GET", "/songs")
elif args.cmd == "downloads": elif args.cmd == "downloads":
status, payload = call(host, port, "GET", "/downloads") status, payload = request(args, host, port, "GET", "/downloads")
elif args.cmd == "history": elif args.cmd == "history":
status, payload = call(host, port, "GET", "/history") status, payload = request(args, host, port, "GET", "/history")
elif args.cmd == "stats": elif args.cmd == "stats":
status, payload = call(host, port, "GET", "/stats") status, payload = request(args, host, port, "GET", "/stats")
elif args.cmd == "volume": elif args.cmd == "volume":
if args.value is None: if args.value is None:
status, payload = call(host, port, "GET", "/volume") status, payload = request(args, host, port, "GET", "/volume")
else: else:
# Treat 0..100 as a percentage of rodio's default 1.0. # Treat 0..100 as a percentage of rodio's default 1.0.
v = args.value v = args.value
if v > 2.0: if v > 2.0:
v = v / 100.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": elif args.cmd == "repeat":
if args.mode is None: if args.mode is None:
status, payload = call(host, port, "GET", "/repeat") status, payload = request(args, host, port, "GET", "/repeat")
else: 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": elif args.cmd == "playlists":
status, payload = call(host, port, "GET", "/playlists") status, payload = request(args, host, port, "GET", "/playlists")
elif args.cmd == "help": elif args.cmd == "help":
status, payload = call(host, port, "GET", "/help") status, payload = request(args, host, port, "GET", "/help")
elif args.cmd == "pl-new": 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": 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": elif args.cmd == "pl-add":
if not q: if not q:
print("error: 'pl-add' requires a song query", file=sys.stderr) print("error: 'pl-add' requires a song query", file=sys.stderr)
sys.exit(2) sys.exit(2)
status, payload = call( status, payload = request(
host, port, "POST", f"{pl_route(args.name)}/add", body={"query": q} args, host, port, "POST", f"{pl_route(args.name)}/add", body={"query": q}
) )
elif args.cmd == "pl-play": 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": 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": elif args.cmd == "pl-rm-song":
status, payload = call( status, payload = request(
host, port, "DELETE", f"{pl_route(args.name)}/songs/{args.song_id}" args, host, port, "DELETE", f"{pl_route(args.name)}/songs/{args.song_id}"
) )
elif args.cmd == "pl-rename": elif args.cmd == "pl-rename":
status, payload = call( status, payload = request(
host, port, "PATCH", pl_route(args.old), body={"name": args.new} args, host, port, "PATCH", pl_route(args.old), body={"name": args.new}
) )
elif args.cmd == "pl-dup": elif args.cmd == "pl-dup":
status, payload = call( status, payload = request(
host, port, "POST", f"{pl_route(args.src)}/duplicate", body={"name": args.dest} args, host, port, "POST", f"{pl_route(args.src)}/duplicate", body={"name": args.dest}
) )
elif args.cmd == "pl-reorder": elif args.cmd == "pl-reorder":
status, payload = call( status, payload = request(
host, port, "POST", f"{pl_route(args.name)}/reorder", args, host, port, "POST", f"{pl_route(args.name)}/reorder",
body={"from": args.from_pos, "to": args.to_pos}, body={"from": args.from_pos, "to": args.to_pos},
) )
else: else:
status, payload = call(host, port, "GET", "/help") status, payload = request(args, host, port, "GET", "/help")
print_response(args, status, payload) print_response(args, status, payload)
+7
View File
@@ -261,6 +261,12 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]] [[package]]
name = "bindgen" name = "bindgen"
version = "0.72.1" version = "0.72.1"
@@ -1562,6 +1568,7 @@ dependencies = [
name = "sjnmusicd" name = "sjnmusicd"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"base64",
"dirs", "dirs",
"env_logger", "env_logger",
"log", "log",
+5
View File
@@ -16,6 +16,11 @@ env_logger = "0.11"
toml = "0.8" toml = "0.8"
percent-encoding = "2" percent-encoding = "2"
rand = "0.8" 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 # `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 # 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 # closed. We run zbus inside a single-threaded tokio runtime hosted by the
+206
View File
@@ -0,0 +1,206 @@
//! Authentication helpers for the optional bearer-token scheme.
//!
//! The daemon can be configured to require `Authorization: Bearer <token>`
//! 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<String> {
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"
);
}
}
+27
View File
@@ -116,9 +116,36 @@ pub struct Config {
pub search: SearchConfig, pub search: SearchConfig,
#[serde(default)] #[serde(default)]
pub download: DownloadConfig, pub download: DownloadConfig,
/// When `true`, the daemon requires `Authorization: Bearer <token>`
/// 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 { 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 /// 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. /// it with sensible defaults so the daemon can be configured on first run.
pub fn load() -> Self { pub fn load() -> Self {
+89 -2
View File
@@ -5,6 +5,7 @@ use log::{debug, error, info, warn};
use crate::{ use crate::{
audio::spawn_audio_thread, audio::spawn_audio_thread,
auth,
config::Config, config::Config,
db::open_db, db::open_db,
mpris, mpris,
@@ -12,9 +13,95 @@ use crate::{
state::{DaemonState, RepeatMode, Song}, 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 <token>)"
);
} 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<String> {
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"); 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 conn = open_db();
let audio_handle = Arc::new(spawn_audio_thread()); let audio_handle = Arc::new(spawn_audio_thread());
@@ -35,7 +122,7 @@ pub fn run(cfg: Config) {
let server_handle = thread::Builder::new() let server_handle = thread::Builder::new()
.name("sjnmusic-http".into()) .name("sjnmusic-http".into())
.spawn(move || { .spawn(move || {
start_server(server_state, &host, port); start_server(server_state, &host, port, auth_token);
}); });
if let Err(e) = server_handle { if let Err(e) = server_handle {
error!("Failed to spawn HTTP server thread: {e}"); error!("Failed to spawn HTTP server thread: {e}");
+1
View File
@@ -1,4 +1,5 @@
mod audio; mod audio;
mod auth;
mod config; mod config;
mod daemon; mod daemon;
mod db; mod db;
+153 -3
View File
@@ -5,6 +5,7 @@ use serde::Serialize;
use tiny_http::{Header, Method, Request, Response, Server, StatusCode}; use tiny_http::{Header, Method, Request, Response, Server, StatusCode};
use crate::audio::AudioCmd; use crate::audio::AudioCmd;
use crate::auth;
use crate::state::{init_async, init_batch, DaemonState, RepeatMode}; use crate::state::{init_async, init_batch, DaemonState, RepeatMode};
use crate::youtube::{pick_best, rank_query, AUTO_PICK_MARGIN}; use crate::youtube::{pick_best, rank_query, AUTO_PICK_MARGIN};
@@ -37,12 +38,24 @@ fn parse_body(body: &str) -> serde_json::Result<serde_json::Value> {
} }
} }
pub fn start_server(state: Arc<Mutex<DaemonState>>, host: &str, port: u16) { pub fn start_server(
state: Arc<Mutex<DaemonState>>,
host: &str,
port: u16,
auth_token: Option<String>,
) {
let addr = format!("{host}:{port}"); let addr = format!("{host}:{port}");
let server = match Server::http(&addr) { let server = match Server::http(&addr) {
Ok(s) => { Ok(s) => {
info!("HTTP server listening on http://{addr}"); info!("HTTP server listening on http://{addr}");
info!("Set RUST_LOG=debug for verbose logs"); info!("Set RUST_LOG=debug for verbose logs");
if auth_token.is_some() {
info!(
"Bearer-token authentication enabled (remote peers require Authorization: Bearer <token>)"
);
} else {
info!("Authentication disabled (all peers trusted)");
}
s s
} }
Err(e) => { Err(e) => {
@@ -55,8 +68,11 @@ pub fn start_server(state: Arc<Mutex<DaemonState>>, host: &str, port: u16) {
let state = state.clone(); let state = state.clone();
let url = request.url().to_string(); let url = request.url().to_string();
let method = request.method().clone(); let method = request.method().clone();
debug!("{} {}", method, url); // Clone per request so handlers can borrow it; tiny_http consumes
if let Err(e) = handle(request, state, &method, &url) { // `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}"); warn!("Failed to respond: {e}");
} }
} }
@@ -73,14 +89,31 @@ fn handle(
state: Arc<Mutex<DaemonState>>, state: Arc<Mutex<DaemonState>>,
method: &Method, method: &Method,
url: &str, url: &str,
auth_token: &Option<String>,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let path = url.split('?').next().unwrap_or(url); let path = url.split('?').next().unwrap_or(url);
let route = route_for(method, path); 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) { if matches!(route, Route::Help) {
let resp = json_response(200, &serde_json::json!({ let resp = json_response(200, &serde_json::json!({
"endpoints": [ "endpoints": [
"GET /help", "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 /songs?q=",
"GET /search?q=... (best match) or POST /search {query}", "GET /search?q=... (best match) or POST /search {query}",
"GET /search/all?q=... (every match above threshold)", "GET /search/all?q=... (every match above threshold)",
@@ -122,6 +155,45 @@ fn handle(
return Ok(request.respond(resp)?); 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(|| "<unknown>".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(); let mut body_text = String::new();
if matches!(method, Method::Post | Method::Delete | Method::Put | Method::Patch) { if matches!(method, Method::Post | Method::Delete | Method::Put | Method::Patch) {
match read_body(&mut request) { match read_body(&mut request) {
@@ -152,6 +224,13 @@ fn handle(
let response = match route { let response = match route {
Route::Help => unreachable!(), 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 => { Route::Songs => {
let q = query_params.get("q").map(|s| s.as_str()); let q = query_params.get("q").map(|s| s.as_str());
@@ -490,6 +569,73 @@ fn json_response_now_playing(state: &DaemonState) -> Response<std::io::Cursor<Ve
) )
} }
fn handle_auth_token(
request: Request,
auth_token: &Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
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<String>,
) -> Result<(), Box<dyn std::error::Error>> {
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)] #[derive(Debug)]
enum AudioRoute { enum AudioRoute {
Pause, Pause,
@@ -524,6 +670,8 @@ fn handle_audio_route(
#[derive(Debug)] #[derive(Debug)]
enum Route { enum Route {
Help, Help,
AuthTokenGet,
AuthPair,
Songs, Songs,
Search, Search,
SearchAll, SearchAll,
@@ -565,6 +713,8 @@ fn route_for(method: &Method, path: &str) -> Route {
let p = path.trim_end_matches('/'); let p = path.trim_end_matches('/');
match (method, p) { match (method, p) {
(Method::Get, "") | (Method::Get, "/help") => Route::Help, (Method::Get, "") | (Method::Get, "/help") => Route::Help,
(Method::Get, "/auth/token") => Route::AuthTokenGet,
(Method::Post, "/auth/pair") => Route::AuthPair,
(Method::Get, "/songs") => Route::Songs, (Method::Get, "/songs") => Route::Songs,
(m, "/search") if matches!(m, Method::Get | Method::Post) => Route::Search, (m, "/search") if matches!(m, Method::Get | Method::Post) => Route::Search,
(Method::Get, "/search/all") => Route::SearchAll, (Method::Get, "/search/all") => Route::SearchAll,