Skip to content

v1.3.11 — argos user reset-password CLI

Why this exists

Reported during dogfooding: an operator forgot the admin password after a deploy. The browser panel has no public reset endpoint -- by design, since "anyone can reset the admin password" is a worse failure mode than "operator must shell into the box". The OS-level /argos --help flag printed the server banner and bound-already-in-use error rather than a CLI surface, because the binary parsed unknown args silently and fell through to run(). Recovery required an out-of-band SQLite hack: stop the container, generate a bcrypt hash by hand with htpasswd, UPDATE users SET password_hash=... directly. Tedious and error-prone.

What's new

argos --help

Prints the actual CLI surface. Was previously silent.

$ docker compose exec argos /argos --help
argos 1.3.11 -- self-hosted edge gateway

Usage:
  argos                              start the HTTP server (default)
  argos server                       start the HTTP server (explicit)
  argos user list                    list panel users
  argos user reset-password <user>   reset a user's password
  argos disable-2fa --user <user>    --yes  remove TOTP for a locked-out user
  argos migrate ...                  run / rollback DB migrations
  argos restore ...                  stage a backup for restore
  argos -h | --help                  show this help

argos user list

$ docker compose exec argos /argos user list
ID    USERNAME                          TOTP  PWD  CREATED
--------------------------------------------------------------------------------
1     admin                             off   yes  2026-04-24T16:34:10Z

PWD: yes means the row has a local password hash; OIDC-only rows show -. TOTP: on reflects the users.totp_enabled column.

argos user reset-password <user>

Two modes:

Interactive (default; recommended):

$ docker compose exec -it argos /argos user reset-password admin
New password: <hidden>
Confirm new password: <hidden>
password reset for user "admin" (user_id=1) at 2026-04-25T08:41:10Z

Echo is suppressed via golang.org/x/term.ReadPassword when stdin is a TTY (-it on docker compose exec). The prompt runs twice and the two reads must match -- prevents fat-finger resets.

Non-interactive (for scripts):

$ docker compose exec argos /argos user reset-password admin --password 'NewPassRotated1!'
password reset for user "admin" (user_id=1) at 2026-04-25T08:41:10Z

The --password flag leaks the value to the operator's shell history. Use only when an interactive operator isn't going to run it.

Both modes:

  • Validate the user exists; bail out with a "run argos user list" hint otherwise.
  • Enforce the same minimum length (8 chars) the panel API enforces -- via auth.HashPassword so the rule lives in one place.
  • Generate a bcrypt cost-12 hash (auth.BcryptCost constant).
  • UPDATE users SET password_hash=? WHERE id=? in a single transaction.
  • Insert an audit / password_reset row into log_entries with source: cli so the operator can see the event in the panel Logs tab after logging back in. Audit failures are logged to stderr but don't fail the command -- the password is already changed.
  • SQLite WAL mode means the running panel keeps serving while the CLI writes; the next login attempt picks up the new hash. No container restart required.

Why this is a separate command from disable-2fa

The existing argos disable-2fa --user <user> --yes covers "locked out of TOTP". This release adds the matching "locked out of password" path. They share the same env contract (ARGOS_DB_PATH), the same audit-log shape, and deliberately don't compose into a single "reset everything" command -- the operator should know which credential is broken.

Bug found mid-smoke

First version of the command was tested with argos user reset-password admin --password X and parsed it as "username = admin, then 2 unexpected positional args (--password, X)". Go's flag.Parse stops at the first non-flag arg by default; the natural operator-typed shape "positional then flags" doesn't work without explicit handling.

Fixed by extracting the username manually as args[0] before constructing the FlagSet, then parsing the remainder. Verified both forms work and added TestRunUserResetPasswordParsesPositionalThenFlags to lock the behaviour in.

Smoke results (prod stack argos-prod-argos:v1.3.11)

$ docker exec argos-prod-panel /argos user list
ID    USERNAME                          TOTP  PWD  CREATED
--------------------------------------------------------------------------------
1     admin                             off   yes  2026-04-24T16:34:10Z

$ docker exec argos-prod-panel /argos user reset-password admin --password 'NewPassRotated1!'
password reset for user "admin" (user_id=1) at 2026-04-25T08:41:02Z

$ curl -X POST http://localhost:9180/api/auth/login \
       -d '{"username":"admin","password":"NewPassRotated1!"}'
{"username":"admin"}             # logged in

$ curl -X POST http://localhost:9180/api/auth/login \
       -d '{"username":"admin","password":"OLD-password"}'
{"error":"invalid credentials"}  # old hash now rejected

$ sqlite3 argos.db "SELECT message FROM log_entries WHERE raw LIKE '%password_reset%' ORDER BY id DESC LIMIT 1"
password reset via CLI

Restored the original prod password via the same command after the smoke run. The panel didn't restart at any point.

Tests

8 unit tests in backend/cmd/argos/cli_user_test.go:

  • TestResetPasswordNonInteractiveUpdatesHash -- new password validates via bcrypt; old password rejected.
  • TestResetPasswordWritesAuditRow -- exactly one cli / password_reset row inserted into log_entries.
  • TestResetPasswordRejectsShortPassword -- "must be at least 8 characters" surfaced verbatim from auth.HashPassword, hash NOT touched on failure.
  • TestResetPasswordUnknownUser -- error mentions the offending username.
  • TestResetPasswordInteractiveMatchAndMismatch -- two sub-tests: matching prompts succeed and update; mismatching prompts return "passwords do not match" without touching the DB.
  • TestResetPasswordRequiresDBPath -- missing ARGOS_DB_PATH (and no --db flag) errors cleanly.
  • TestRunUserResetPasswordParsesPositionalThenFlags -- natural arg order works; bare --password X (no username) errors.
  • TestResetPasswordRejectsBlankUsername -- whitespace-only username rejected before any DB connection is opened.

Files changed

  • backend/cmd/argos/main.go -- top-level dispatcher gains user, server, --help/-h/help cases. New printRootUsage writer.
  • backend/cmd/argos/cli_user.go (new) -- 200 lines. runUserCommand, runUserResetPassword, runUserList, resetPasswordWithOpts (testable core), readPasswordFromTerm.
  • backend/cmd/argos/cli_user_test.go (new) -- 8 tests.
  • backend/go.mod / backend/go.sum -- x/term promoted to direct dep. Tidy also promoted go-oidc/v3 + oauth2 (already used directly elsewhere; metadata cleanup only).
  • docs/operations/troubleshooting.md -- new "Forgot admin password" section with interactive / non-interactive / panel-down / sqlite3-fallback recovery paths.

Upgrade

cd argos-edge
git pull
docker compose build
docker compose up -d

The CLI is in the binary; no migration / config / volume work. Existing disable-2fa / migrate / restore subcommands are unchanged.

Not changed

  • The HTTP API has no password-reset endpoint, by design. CLI only.
  • disable-2fa retains its --yes confirmation flag; reset-password does NOT require a confirmation flag because the prompt-twice interactive flow already serves that role and the non-interactive flow is meant for non-confirmation scripts.
  • Audit-row schema unchanged; the new entries fit the existing source / level / message / raw shape used by disable-2fa and the in-process logs.Recorder.