add auth system
This commit is contained in:
+166
-53
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Generated
+7
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
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
@@ -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,4 +1,5 @@
|
|||||||
mod audio;
|
mod audio;
|
||||||
|
mod auth;
|
||||||
mod config;
|
mod config;
|
||||||
mod daemon;
|
mod daemon;
|
||||||
mod db;
|
mod db;
|
||||||
|
|||||||
+153
-3
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user