add auth system
This commit is contained in:
+166
-53
@@ -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 <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")
|
||||
|
||||
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 <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):
|
||||
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)
|
||||
|
||||
|
||||
Generated
+7
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -116,9 +116,36 @@ pub struct Config {
|
||||
pub search: SearchConfig,
|
||||
#[serde(default)]
|
||||
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 {
|
||||
/// 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 {
|
||||
|
||||
+89
-2
@@ -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 <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");
|
||||
|
||||
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}");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
mod audio;
|
||||
mod auth;
|
||||
mod config;
|
||||
mod daemon;
|
||||
mod db;
|
||||
|
||||
+153
-3
@@ -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<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 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 <token>)"
|
||||
);
|
||||
} else {
|
||||
info!("Authentication disabled (all peers trusted)");
|
||||
}
|
||||
s
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -55,8 +68,11 @@ pub fn start_server(state: Arc<Mutex<DaemonState>>, 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<Mutex<DaemonState>>,
|
||||
method: &Method,
|
||||
url: &str,
|
||||
auth_token: &Option<String>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
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(|| "<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();
|
||||
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<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)]
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user