#!/usr/bin/env python3 """Add, list and remove Media Centre dashboard accounts. Passwords are prompted for interactively (never echoed, never taken as a command-line argument, so they don't land in shell history) and only ever stored as a salted pbkdf2 hash in settings.json. Nothing here writes a plaintext password anywhere. Run it inside the container, where Werkzeug and the real settings.json are: docker exec -it arr-summary python3 manage_users.py list docker exec -it arr-summary python3 manage_users.py add docker exec -it arr-summary python3 manage_users.py passwd docker exec -it arr-summary python3 manage_users.py remove The first `add` switches the dashboard from its single shared password to per-user logins. Until then the old LOGIN_PASSWORD keeps working, so you can't lock yourself out by deploying this. """ import sys import getpass from app import ( SETTINGS_FILE, PASSWORD_HASH_METHOD, save_settings, load_users, generate_password_hash, ) MIN_LEN = 6 def _prompt_new_password(username): """Ask twice, confirm they match, never echo. None if aborted.""" pw = getpass.getpass(f"New password for {username!r}: ") if not pw: print("Aborted: empty password.", file=sys.stderr) return None if len(pw) < MIN_LEN: print(f"Aborted: must be at least {MIN_LEN} characters.", file=sys.stderr) return None if pw != getpass.getpass("Confirm: "): print("Aborted: passwords don't match.", file=sys.stderr) return None return pw def cmd_list(_args): users = load_users() if not users: print("No accounts configured — the dashboard is still using the") print("single shared password (LOGIN_PASSWORD / settings.json).") return 0 print(f"{len(users)} account(s) in {SETTINGS_FILE}:") for name in sorted(users): print(f" {name}") return 0 def _set_password(username, require_existing): users = load_users() exists = username in users if require_existing and not exists: print(f"No such account: {username!r}. Use `add` to create it.", file=sys.stderr) return 1 if not require_existing and exists: print(f"Account {username!r} already exists. Use `passwd` to change it.", file=sys.stderr) return 1 pw = _prompt_new_password(username) if pw is None: return 1 users[username] = generate_password_hash(pw, method=PASSWORD_HASH_METHOD) save_settings({"users": users}) print(f"{'Updated' if exists else 'Created'} {username!r} in {SETTINGS_FILE}.") if not exists and len(users) == 1: print() print("This is the first account, so the dashboard has just switched to") print("per-user logins. The old shared password no longer signs in —") print("make sure you can log in as this user before closing this shell.") return 0 def cmd_add(args): if not args: print("Usage: manage_users.py add ", file=sys.stderr) return 2 return _set_password(args[0].strip(), require_existing=False) def cmd_passwd(args): if not args: print("Usage: manage_users.py passwd ", file=sys.stderr) return 2 return _set_password(args[0].strip(), require_existing=True) def cmd_remove(args): if not args: print("Usage: manage_users.py remove ", file=sys.stderr) return 2 username = args[0].strip() users = load_users() if username not in users: print(f"No such account: {username!r}", file=sys.stderr) return 1 if len(users) == 1: print("Refusing to remove the last account — that would drop the dashboard", file=sys.stderr) print("back to the shared password, which is probably not what you meant.", file=sys.stderr) print("Add another account first, or clear `users` from settings.json by hand.", file=sys.stderr) return 1 del users[username] save_settings({"users": users}) print(f"Removed {username!r}. {len(users)} account(s) remain.") return 0 COMMANDS = {"list": cmd_list, "add": cmd_add, "passwd": cmd_passwd, "remove": cmd_remove} def main(argv): if len(argv) < 2 or argv[1] not in COMMANDS: print(__doc__.strip()) return 2 return COMMANDS[argv[1]](argv[2:]) if __name__ == "__main__": sys.exit(main(sys.argv))