arr-summary/manage_users.py
Barely Removable 2f73367c6b Add a guarded way to retire the plaintext shared password
settings.json can still hold the old plaintext login_password after
accounts exist, where it's ignored but sitting in cleartext. Removing it
by hand has a sharp edge: with no accounts AND no login_password, the
login route reads "nothing configured" as dev mode and lets anyone in
without a password, so a tidy-up can quietly open the dashboard.

`manage_users.py retire-shared-password` removes the key only when at
least one account exists, and says so plainly when it won't. It also
warns if LOGIN_PASSWORD is still set in the environment, since that's a
second copy people forget.

Needed delete_setting() in app.py — save_settings() only ever merges, so
it had no way to take a key away.

Verified: refuses with exit 1 when no accounts exist and leaves the file
untouched; removes the key while preserving timezone and users; is
idempotent; warns about the env var; and after removal only the account
password signs in, with the retired shared password and the env value
both rejected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 13:06:31 -07:00

188 lines
6.4 KiB
Python

#!/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 <username>
docker exec -it arr-summary python3 manage_users.py passwd <username>
docker exec -it arr-summary python3 manage_users.py remove <username>
docker exec -it arr-summary python3 manage_users.py retire-shared-password
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.
Once accounts exist and you've confirmed they sign in,
`retire-shared-password` deletes the old plaintext login_password from
settings.json. It refuses to run while no accounts exist, because with
nothing configured at all the dashboard treats that as dev mode and lets
anyone in without a password.
"""
import sys
import getpass
from app import (
SETTINGS_FILE,
PASSWORD_HASH_METHOD,
LOGIN_PASSWORD,
save_settings,
delete_setting,
load_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 <username>", 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 <username>", 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 <username>", 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
def cmd_retire_shared_password(_args):
"""Delete the plaintext login_password from settings.json.
Guarded, because removing it while nothing else is configured makes
get_active_password() empty — and the login route reads "no credentials
at all" as dev mode and lets anyone straight in.
"""
users = load_users()
if not users:
print("Refusing: no accounts exist yet, so the shared password is still",
file=sys.stderr)
print("the only way in. Run `add <username>` first and check you can",
file=sys.stderr)
print("sign in with it, then run this again.", file=sys.stderr)
return 1
if "login_password" not in load_settings():
print("Nothing to do — settings.json has no login_password.")
if LOGIN_PASSWORD:
print()
print("Note: LOGIN_PASSWORD is still set in the environment. It's")
print("ignored while accounts exist, but clear it from .env too if")
print("you want the old shared password gone entirely.")
return 0
delete_setting("login_password")
print(f"Removed the plaintext login_password from {SETTINGS_FILE}.")
print(f"{len(users)} account(s) remain in use: {', '.join(sorted(users))}")
if LOGIN_PASSWORD:
print()
print("LOGIN_PASSWORD is still set in the environment. It's ignored while")
print("accounts exist, but clear it from .env and restart to be rid of it.")
print("Don't clear it before you've confirmed the accounts work.")
return 0
COMMANDS = {
"list": cmd_list,
"add": cmd_add,
"passwd": cmd_passwd,
"remove": cmd_remove,
"retire-shared-password": cmd_retire_shared_password,
}
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))