jellyfin-mint-api-key.py
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env python3
"""Mint a new Jellyfin API key via the HTTP API (never raw SQL, which can corrupt the DB).
Snapshots the existing keys first, then verifies that the new key works, every pre-existing key
still works, and an admin user can still log in. On a failed verification it exits non-zero
WITHOUT rolling back: revoke manually in the Jellyfin admin UI, comparing against the printed
pre-flight snapshot.
Usage (read secrets without echo, to keep them out of shell history and `ps` argv):
read -rsp 'JELLYFIN_API_KEY: ' JELLYFIN_API_KEY && echo
read -rsp 'JELLYFIN_VERIFY_USER: ' JELLYFIN_VERIFY_USER && echo
read -rsp 'JELLYFIN_VERIFY_PW: ' JELLYFIN_VERIFY_PW && echo
export JELLYFIN_API_KEY JELLYFIN_VERIFY_USER JELLYFIN_VERIFY_PW
uv run ./jellyfin-mint-api-key.py [--dry-run] <new-key-name>
unset JELLYFIN_API_KEY JELLYFIN_VERIFY_USER JELLYFIN_VERIFY_PW
Env:
JELLYFIN_API_KEY existing admin-scoped API key. REQUIRED.
JELLYFIN_URL base URL (default: http://$MEDIA_LAN_IP:8096)
JELLYFIN_VERIFY_USER username for the post-mint login round-trip.
JELLYFIN_VERIFY_PW that user's password. Both REQUIRED unless --skip-login-test.
Exit codes:
0 key minted + all verifications passed
1 pre-flight failed (existing key invalid, server unreachable)
2 mint failed (non-2xx); the new key MAY have landed
3 post-flight verification failed; the new key MAY have landed, so reconcile manually
from the printed before/after snapshot
4 bad usage (missing env, bad argv)
The new key is printed once, at the end, on success. Capture it immediately.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import urllib.parse
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from media_stack.clients.jellyfin import JellyfinClient, NetworkError
def _default_url() -> str:
try:
from media_stack.paths import MEDIA_LAN_IP
return f"http://{MEDIA_LAN_IP}:8096"
except Exception:
# same literal as media_stack.paths' fallback
return "http://10.0.0.10:8096"
DEFAULT_URL = _default_url()
def _redact(token: str) -> str:
"""Last 4 chars only; tokens of 4 or fewer get a fixed marker (`token[-4:]` would reveal them whole)."""
if not token:
return ""
if len(token) <= 4:
return "…<short-redacted>"
return f"…{token[-4:]}"
class _UsageError(Exception):
"""Maps argparse failures to the documented rc=4 instead of argparse's 2."""
class _UsageParser(argparse.ArgumentParser):
def error(self, message: str) -> None: # type: ignore[override]
self.print_usage(sys.stderr)
raise _UsageError(message)
def main() -> int:
p = _UsageParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("name", help="Name for the new API key (e.g. 'claude-ops')")
p.add_argument("--dry-run", action="store_true",
help="Run pre-flight + verifications without minting")
p.add_argument("--skip-login-test", action="store_true",
help="Skip the post-mint admin login round-trip")
try:
args = p.parse_args()
except _UsageError as e:
print(f"FATAL: usage: {e}", file=sys.stderr)
return 4
base = os.environ.get("JELLYFIN_URL", DEFAULT_URL).rstrip("/")
bootstrap_key = os.environ.get("JELLYFIN_API_KEY", "")
verify_user = os.environ.get("JELLYFIN_VERIFY_USER", "")
verify_pw = os.environ.get("JELLYFIN_VERIFY_PW", "")
if not bootstrap_key:
print("FATAL: JELLYFIN_API_KEY env var required", file=sys.stderr)
return 4
if not args.skip_login_test and (not verify_user or not verify_pw):
print("FATAL: JELLYFIN_VERIFY_USER + JELLYFIN_VERIFY_PW required "
"(or pass --skip-login-test)", file=sys.stderr)
return 4
# strip once: an untrimmed `" Jellyseerr "` would bypass the duplicate check
args.name = args.name.strip()
if not args.name:
print("FATAL: key name must be non-empty", file=sys.stderr)
return 4
client = JellyfinClient(base)
print(f"[pre-flight] base URL: {base}", file=sys.stderr)
print("[pre-flight] verifying bootstrap key works...", file=sys.stderr)
# every pre-flight failure must surface as rc=1 with a FATAL line, not a traceback
try:
status, raw = client.request("/System/Info", bootstrap_key)
except NetworkError as e:
print(f"FATAL: cannot reach Jellyfin: {e}", file=sys.stderr)
return 1
if status != 200:
print(f"FATAL: bootstrap key check failed ({status}): {raw[:200]!r}",
file=sys.stderr)
return 1
try:
info = json.loads(raw)
except ValueError as e:
print(f"FATAL: /System/Info returned non-JSON: {type(e).__name__}: "
f"{e} ({raw[:200]!r})", file=sys.stderr)
return 1
print(f"[pre-flight] connected to {info.get('ServerName')} v{info.get('Version')}",
file=sys.stderr)
print("[pre-flight] snapshotting current ApiKeys table...", file=sys.stderr)
try:
pre_keys = client.list_keys(bootstrap_key)
except NetworkError as e:
print(f"FATAL: cannot reach Jellyfin during snapshot: {e}", file=sys.stderr)
return 1
except (RuntimeError, ValueError) as e:
print(f"FATAL: pre-flight snapshot failed: {type(e).__name__}: {e}",
file=sys.stderr)
return 1
print(f"[pre-flight] found {len(pre_keys)} existing key(s):", file=sys.stderr)
for k in pre_keys:
print(f" - Name={k.get('AppName')!r} token={_redact(k.get('AccessToken',''))} "
f"created={k.get('DateCreated')}", file=sys.stderr)
pre_names = {k.get("AppName") for k in pre_keys}
if args.name in pre_names:
print(f"FATAL: a key named {args.name!r} already exists — refusing to "
f"add a duplicate (would shadow the existing one)", file=sys.stderr)
return 4
pre_tokens = {k.get("AccessToken") for k in pre_keys}
if args.dry_run:
print("[dry-run] would POST /Auth/Keys?App="
+ urllib.parse.quote(args.name, safe=""), file=sys.stderr)
print("[dry-run] would verify: new key works + all pre-existing keys "
"still work + admin login still works", file=sys.stderr)
return 0
# all three map to rc=2 "MAY have landed": the error site can't tell whether the POST ran
print(f"[mint] POST /Auth/Keys?App={args.name!r}...", file=sys.stderr)
try:
client.create_key(bootstrap_key, args.name)
post_keys = client.list_keys(bootstrap_key)
except (NetworkError, RuntimeError, ValueError) as e:
print(f"FATAL: mint/post-list failed: {type(e).__name__}: {e}",
file=sys.stderr)
print(" The mint MAY have landed — check Jellyfin admin UI "
"(Dashboard → API Keys) to confirm + revoke if unexpected.",
file=sys.stderr)
return 2
new_keys = [k for k in post_keys
if k.get("AccessToken") not in pre_tokens]
if len(new_keys) != 1 or new_keys[0].get("AppName") != args.name:
print(f"FATAL: expected exactly 1 new key named {args.name!r}, got "
f"{len(new_keys)}: {[(k.get('AppName'), _redact(k.get('AccessToken',''))) for k in new_keys]}",
file=sys.stderr)
return 2
new_token = new_keys[0]["AccessToken"]
print(f"[mint] new key created: Name={args.name!r} token={_redact(new_token)}",
file=sys.stderr)
# the mint is proven to have landed, so failures here are rc=3, not rc=2
print("[verify] confirming new key works against /System/Info...",
file=sys.stderr)
try:
status, _ = client.request("/System/Info", new_token)
except NetworkError as e:
print(f"FATAL: verify of new key failed (network): {e}", file=sys.stderr)
return 3
if status != 200:
print(f"FATAL: new key failed verification: HTTP {status}", file=sys.stderr)
return 3
print("[verify] new key: OK", file=sys.stderr)
print(f"[verify] confirming all {len(pre_keys)} pre-existing key(s) still work...",
file=sys.stderr)
for k in pre_keys:
tok = k.get("AccessToken", "")
name = k.get("AppName", "?")
try:
status, _ = client.request("/System/Info", tok)
except NetworkError as e:
print(f"FATAL: verify of pre-existing key {name!r} "
f"({_redact(tok)}) failed (network): {e}", file=sys.stderr)
return 3
if status != 200:
print(f"FATAL: pre-existing key {name!r} ({_redact(tok)}) STOPPED "
f"working: HTTP {status}", file=sys.stderr)
return 3
print(f"[verify] {name!r} ({_redact(tok)}): OK", file=sys.stderr)
if not args.skip_login_test:
print(f"[verify] confirming login as {verify_user!r} still works "
f"AND retains IsAdministrator...", file=sys.stderr)
try:
code, auth_body = client.authenticate(verify_user, verify_pw)
except NetworkError as e:
print(f"FATAL: login test failed (network): {e}", file=sys.stderr)
return 3
if code != 200:
print(f"FATAL: login as {verify_user!r} returned HTTP {code} "
f"(expected 200) — admin auth may be broken", file=sys.stderr)
return 3
# strict `is True`: a truthy string ("false", "0") must not pass as admin
try:
auth_data = json.loads(auth_body)
policy = (auth_data.get("User") or {}).get("Policy") or {}
is_admin = policy.get("IsAdministrator") is True
except (ValueError, AttributeError) as e:
print(f"FATAL: could not parse auth response to check admin "
f"policy: {type(e).__name__}: {e}", file=sys.stderr)
return 3
if not is_admin:
# never echo the raw value: response-derived, could leak token-like content into the
# Telegram alert wrapper
policy_present = (auth_data.get("User") or {}).get("Policy") is not None
ia_present = "IsAdministrator" in policy
ia_type = type(policy.get("IsAdministrator")).__name__
print(f"FATAL: login as {verify_user!r} succeeded but "
f"IsAdministrator is not exactly True — admin policy "
f"was disrupted (Policy present={policy_present}, "
f"IsAdministrator present={ia_present}, type={ia_type})",
file=sys.stderr)
return 3
print(f"[verify] {verify_user!r}: OK (admin=True)", file=sys.stderr)
print("", file=sys.stderr)
print(f"==== new Jellyfin API key (name={args.name!r}) — capture now ====")
print(new_token)
print("===================================================================")
return 0
if __name__ == "__main__":
sys.exit(main())