Compare commits

...

2 commits

Author SHA1 Message Date
Barely Removable
6f4958ba89 Bump to v1.6.1 for deploy
All checks were successful
Deploy / deploy (push) Successful in 22s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 13:06:31 -07:00
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
2 changed files with 67 additions and 2 deletions

15
app.py
View file

@ -22,7 +22,7 @@ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
# Docker tab caches stale container IDs, so the footer is the reliable way # Docker tab caches stale container IDs, so the footer is the reliable way
# to confirm a `docker compose up -d --build` actually took effect. # to confirm a `docker compose up -d --build` actually took effect.
# Both are env-overridable if the deploy pipeline ever injects them. # Both are env-overridable if the deploy pipeline ever injects them.
APP_VERSION = os.environ.get("APP_VERSION", "1.6.0") APP_VERSION = os.environ.get("APP_VERSION", "1.6.1")
APP_VERSION_DATE = os.environ.get("APP_VERSION_DATE", "2026-08-27") APP_VERSION_DATE = os.environ.get("APP_VERSION_DATE", "2026-08-27")
# Must point at a path that survives `docker compose up --build` — the # Must point at a path that survives `docker compose up --build` — the
@ -45,6 +45,19 @@ def save_settings(data):
with open(SETTINGS_FILE, "w") as f: with open(SETTINGS_FILE, "w") as f:
json.dump(existing, f, indent=2) json.dump(existing, f, indent=2)
def delete_setting(key):
"""Remove a key outright. save_settings() only ever merges, so it can't
take something away — used to retire the plaintext shared password once
real accounts exist. Returns True if the key was there."""
settings = load_settings()
if key not in settings:
return False
del settings[key]
os.makedirs(os.path.dirname(SETTINGS_FILE) or ".", exist_ok=True)
with open(SETTINGS_FILE, "w") as f:
json.dump(settings, f, indent=2)
return True
app = Flask(__name__) app = Flask(__name__)
app.secret_key = os.environ.get("SECRET_KEY") or secrets.token_hex(32) app.secret_key = os.environ.get("SECRET_KEY") or secrets.token_hex(32)

View file

@ -12,10 +12,17 @@ Run it inside the container, where Werkzeug and the real settings.json are:
docker exec -it arr-summary python3 manage_users.py add <username> 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 passwd <username>
docker exec -it arr-summary python3 manage_users.py remove <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 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 per-user logins. Until then the old LOGIN_PASSWORD keeps working, so you
can't lock yourself out by deploying this. 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 sys
@ -24,7 +31,10 @@ import getpass
from app import ( from app import (
SETTINGS_FILE, SETTINGS_FILE,
PASSWORD_HASH_METHOD, PASSWORD_HASH_METHOD,
LOGIN_PASSWORD,
save_settings, save_settings,
delete_setting,
load_settings,
load_users, load_users,
generate_password_hash, generate_password_hash,
) )
@ -122,7 +132,49 @@ def cmd_remove(args):
return 0 return 0
COMMANDS = {"list": cmd_list, "add": cmd_add, "passwd": cmd_passwd, "remove": cmd_remove} 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): def main(argv):