add auth system

This commit is contained in:
2026-07-12 18:10:17 +02:00
parent 93be6a7f68
commit 63287fcc8a
8 changed files with 654 additions and 58 deletions
+166 -53
View File
@@ -29,6 +29,16 @@ def build_parser() -> argparse.ArgumentParser:
type=int,
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)