arnaucube
arnaucube/jellyfin-toolsPublic Self-hosted repository
jellyfin-tools/jellyfin-year-review/scripts/export-jellyfin-auth.py
jellyfin-year-review/scripts/export-jellyfin-auth.pyRaw
  1#!/usr/bin/env python3
  2"""Export local Jellyfin 12.2 verifiers without passwords or database writes."""
  3
  4import argparse
  5import contextlib
  6import datetime
  7import json
  8import os
  9from pathlib import Path
 10import re
 11import sqlite3
 12import stat
 13import sys
 14import tempfile
 15import time
 16import uuid
 17
 18PROVIDER = "Jellyfin.Server.Implementations.Users.DefaultAuthenticationProvider"
 19MAX_USERS = 1000
 20MAX_BYTES = 1 << 20
 21
 22
 23def identity(value):
 24    """Match Jellyfin's immutable Guid identity across its two string forms."""
 25    if not isinstance(value, str):
 26        raise ValueError("Expected text UUIDs in the Jellyfin 12.2 schema")
 27    try:
 28        return uuid.UUID(value).hex
 29    except ValueError:
 30        raise ValueError("Invalid account UUID") from None
 31
 32
 33def login_key(name):
 34    """Use ASCII case folding and exact spelling for Unicode account names."""
 35    return ("ascii:" + name.lower()) if name.isascii() else ("unicode:" + name)
 36
 37
 38def validate_hash(encoded):
 39    """Accept the two bounded, salted PBKDF2 encodings supported by Go serving."""
 40    if not isinstance(encoded, str):
 41        raise ValueError("Unsupported password verifier; sign in to Jellyfin to migrate it")
 42    match = re.fullmatch(
 43        r"\$(PBKDF2-SHA512|PBKDF2)\$iterations=([0-9]{1,7})\$([0-9a-fA-F]+)\$([0-9a-fA-F]+)",
 44        encoded,
 45    )
 46    if not match:
 47        raise ValueError("Unsupported password verifier; sign in to Jellyfin to migrate it")
 48    algorithm, iterations, salt_hex, hash_hex = match.groups()
 49    size = 64 if algorithm == "PBKDF2-SHA512" else 32
 50    if not 1 <= int(iterations) <= 1_000_000 or len(salt_hex) % 2 or not 16 <= len(salt_hex) <= 256 or len(hash_hex) != size * 2:
 51        raise ValueError("Password verifier parameters are outside supported bounds")
 52
 53
 54def read_accounts(database):
 55    """Pin one read-only SQLite transaction, including committed WAL contents."""
 56    counts = {"external": 0, "passwordless": 0, "disabled": 0, "restricted": 0}
 57    deadline = time.monotonic() + 60
 58    with contextlib.closing(sqlite3.connect(database.as_uri() + "?mode=ro", uri=True, timeout=5)) as db:
 59        db.execute("PRAGMA query_only=ON")
 60        db.set_progress_handler(lambda: int(time.monotonic() > deadline), 1000)
 61        db.execute("BEGIN")
 62        required = {
 63            "Users": {"Id", "Username", "Password", "AuthenticationProviderId", "MustUpdatePassword"},
 64            "Permissions": {"UserId", "Kind", "Value"},
 65        }
 66        for table, columns in required.items():
 67            actual = {row[1] for row in db.execute(f'PRAGMA table_info("{table}")')}
 68            if not columns <= actual:
 69                raise ValueError("Unsupported database schema; expected Jellyfin 12.2 Users and Permissions")
 70        rows = db.execute('SELECT Id, Username, Password, AuthenticationProviderId, MustUpdatePassword FROM Users LIMIT 1001').fetchall()
 71        if len(rows) > MAX_USERS:
 72            raise ValueError("Account count exceeds the supported limit")
 73        permissions = {}
 74        for user_id, kind, value in db.execute('SELECT UserId, Kind, Value FROM Permissions WHERE Kind IN (2, 4)'):
 75            key = (identity(user_id), kind)
 76            if key in permissions or value not in (0, 1):
 77                raise ValueError("Invalid or duplicate account status permissions")
 78            permissions[key] = bool(value)
 79        scheduled = set()
 80        if db.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='AccessSchedules'").fetchone():
 81            scheduled = {identity(row[0]) for row in db.execute('SELECT DISTINCT UserId FROM AccessSchedules')}
 82        users, ids, names = [], set(), set()
 83        for user_id, name, password_hash, provider, must_update in rows:
 84            user_id = identity(user_id)
 85            if not isinstance(name, str) or not name or len(name.encode("utf-8")) > 256 or any(c in name for c in "\x00\r\n"):
 86                raise ValueError("Invalid account username")
 87            name_key = login_key(name)
 88            if user_id in ids or name_key in names:
 89                raise ValueError("Duplicate account identity or login name")
 90            ids.add(user_id)
 91            names.add(name_key)
 92            if not isinstance(provider, str) or provider.lower() != PROVIDER.lower():
 93                counts["external"] += 1
 94                continue
 95            if (user_id, 2) not in permissions or (user_id, 4) not in permissions:
 96                raise ValueError("Missing explicit disabled or remote-access account permission")
 97            disabled = permissions[user_id, 2]
 98            restricted = not permissions[user_id, 4] or bool(must_update) or user_id in scheduled
 99            if disabled or restricted:
100                counts["disabled" if disabled else "restricted"] += 1
101                users.append({"id": user_id, "username": name, "passwordHash": "", "disabled": True})
102                continue
103            if password_hash is None or password_hash == "":
104                counts["passwordless"] += 1
105                continue
106            validate_hash(password_hash)
107            users.append({"id": user_id, "username": name, "passwordHash": password_hash, "disabled": False})
108        db.rollback()
109    return sorted(users, key=lambda user: user["id"]), counts
110
111
112def private_existing(path):
113    """Refuse accidental symlinks, oversized files or permissive secret copies."""
114    try:
115        info = path.lstat()
116    except FileNotFoundError:
117        return None
118    if not stat.S_ISREG(info.st_mode) or stat.S_IMODE(info.st_mode) & 0o077 or info.st_size > MAX_BYTES:
119        raise ValueError("Existing credential file must be regular, private (0600/0400) and within 1 MiB")
120    return path.read_bytes()
121
122
123def atomic_write(path, content):
124    """Publish a private synced file with atomic rename on the same filesystem."""
125    descriptor, temporary = tempfile.mkstemp(prefix=".auth-", dir=path.parent)
126    try:
127        with os.fdopen(descriptor, "wb") as file:
128            file.write(content)
129            file.flush()
130            os.fsync(file.fileno())
131        os.replace(temporary, path)
132        directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
133        try:
134            os.fsync(directory)
135        finally:
136            os.close(directory)
137    finally:
138        if os.path.exists(temporary):
139            os.unlink(temporary)
140
141
142def export(database, output, exported_at=None):
143    """Keep the last successful credential export intact on source failures."""
144    database = Path(database).resolve(strict=True)
145    output = Path(output).absolute()
146    backup = output.with_name(output.name + ".previous")
147    if database in (output.resolve(), backup.resolve()):
148        raise ValueError("Credential output cannot replace the source database")
149    users, counts = read_accounts(database)
150    document = {
151        "schemaVersion": 1,
152        "sourceVersion": "12.2",
153        "exportedAt": exported_at or datetime.datetime.now(datetime.timezone.utc).isoformat(),
154        "users": users,
155    }
156    payload = (json.dumps(document, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
157    if len(payload) > MAX_BYTES:
158        raise ValueError("Credential export exceeds 1 MiB")
159    output.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
160    previous = private_existing(output)
161    private_existing(backup)
162    if previous is not None:
163        atomic_write(backup, previous)
164    atomic_write(output, payload)
165    return len([user for user in users if not user["disabled"]]), counts
166
167
168def main():
169    parser = argparse.ArgumentParser(description=__doc__)
170    parser.add_argument("--database", required=True, help="readable live Jellyfin 12.2 SQLite DB or safe backup")
171    parser.add_argument("--output", required=True, help="private auth/users.json, outside web and review data")
172    args = parser.parse_args()
173    try:
174        enabled, counts = export(args.database, args.output)
175    except ValueError as error:
176        print(f"export-auth: {error}", file=sys.stderr)
177        return 1
178    except (sqlite3.Error, OSError):
179        print("export-auth: cannot read the database or publish the private export; check schema, paths and permissions", file=sys.stderr)
180        return 1
181    print(f"Exported {enabled} enabled local accounts. Excluded: {counts['external']} external, {counts['passwordless']} passwordless; blocked: {counts['disabled']} disabled, {counts['restricted']} restricted.")
182    return 0
183
184
185if __name__ == "__main__":
186    sys.exit(main())