Skip to content

Login: the operator commands and fleet replication (#261) - #265

Merged
webdevtodayjason merged 4 commits into
mainfrom
fable/login-cli-fleet
Sep 22, 2026
Merged

webdevtodayjason merged 4 commits into
mainfrom
fable/login-cli-fleet

Conversation

@webdevtodayjason

@webdevtodayjason webdevtodayjason commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

The operator half of the dashboard login (#261): the commands that make an
account, and the replication that makes the master's list the fleet's. The
account store and the session routes are a separate branch; this one is written
against their contract and imports UsersStore through one guarded import, so it
lands cleanly either way round.

Two problems this exists to solve. There is no sign-up page and there must not be:
a route that mints the first admin is a route anybody who can reach port 3000
calls before the operator does, so the first account is made on the box. And a
cluster cannot hold one account list per node, because an operator who adds a
login on the master and then opens the dashboard of whichever node a bookmark
points at would be told their password is wrong.

The commands

Everything sits under the existing ainode auth parser, and auth key ...,
auth status|enable|disable keep working exactly as they did.

# The first account. Prompts twice; the store refuses anything under 8 characters.
ainode auth user add jason --admin

# No terminal (a script, a unit, `ssh host ainode ...`): getpass needs a TTY,
# so this is the path. The wrapper fix below is what lets the pipe through.
echo 'the password' | ainode auth user add ci --password-stdin

ainode auth user list                  # name, role, state, session count
ainode auth user passwd jason          # prompts twice, or --password-stdin
ainode auth user disable ops           # keeps the account, stops it signing in
ainode auth user enable ops
ainode auth user remove ops            # refused for the last admin

ainode auth session list --user jason  # this node's sign-ins
ainode auth session revoke 7f3c1a90
ainode auth session clear              # sign everybody out of this node

ainode auth status now prints the accounts half of the same question:

  Auth: enabled
  Keys: 2
  Users: 3 (1 admin)
  Sessions: 4 (this node only, never replicated)

Removing or disabling the LAST admin is refused. With auth on and no admin, the
dashboard can only be opened by pasting an API key, which is the state the login
exists to replace and the state ainode doctor FAILs on.

Replication

ainode/auth/replication.py. The master is the authority for accounts, and
sessions are never replicated, because a session is one browser's credential
against one node: copying them would hand every node in the fleet a credential it
never issued, and revoking one would have to be a fan-out to be true.

  • The master pushes. app["users_changed"] is registered here, so every
    account mutation the routes make POSTs export_users() to
    /api/auth/users/sync on every peer discovery knows about, in the background,
    with fleet_headers. The export carries password hashes, which is why that key
    is the only one the route takes.
  • "Behind" is a comparison, not a queue. The master remembers the stamp each
    peer accepted, so a peer that failed simply still disagrees and the next pass
    pushes to it again. A peer that agrees is not pushed to at all, so an idle
    fleet costs one in-memory comparison per tick and no requests.
  • A worker pulls /api/auth/users/export at startup and every 5 minutes. The
    pull is the safety net under the push, not the mechanism: it covers a node that
    was down when the change happened and a node that has just joined and has no
    accounts at all.
  • A node with no cluster_secret, or with nobody to talk to, does nothing.
    The fleet key is derived from that secret, so such a node could not
    authenticate to a peer and must not pretend to.

Wired in api/server.py's _on_startup beside the other background tasks (after
the cluster secret and the HTTP session exist, since those are what it
authenticates and talks with) and cancelled in _on_cleanup.

When the master is down

  • A change made on the master while a peer is down is pushed, fails, and the
    peer stays behind. The loop wakes every 60 seconds while anything is behind and
    pushes again, so the account lands as soon as that node answers. Nothing is
    queued and nothing is lost, because the comparison is against the current list
    rather than a log of changes.
  • A worker whose master is unreachable keeps the accounts it already has and
    goes on serving. The first failure is one WARNING naming the master and saying
    the node keeps what it has; the rest are debug lines, so an hour of downtime is
    not sixty identical warnings in the log of a node that is working. A failed
    pull comes back on the 60 second retry rather than the full 5 minutes, so a
    worker that came up while its master was still booting converges quickly.
  • A worker with no master named anywhere is a WARN from ainode doctor
    (login.sync), not a silent no-op.
  • ainode auth user add on a worker writes the account and prints one line:
    accounts are managed on the master, and this node will be overwritten by the
    next sync. It is not refused, because refusing would leave an operator who
    typed the command on the wrong box with neither an account nor an explanation.
  • ainode auth user add on the master with the service down still replicates:
    it falls back to the peers config.json names when
    /api/cluster/info does not answer.

Doctor

A Login check, three lines, in the shape every other check in that file has
(plain values in, list[Check] out, the world behind a seam):

  • login.state: OK when a credential is required and at least one enabled admin
    exists, or when auth is off (nothing is refused without one, so a missing
    account is not a finding). FAIL when auth is on with no account at all. WARN
    with accounts but no enabled admin.
  • login.store: WARN when users.json is wider than 0600, since it holds
    password hashes. --fix chmods it, like the secrets store.
  • login.sync: on a WORKER only, WARN when its list is older than the master's
    stamp. It compares the stamp this node recorded when it last imported against
    the one the master reports now, so both values are the master's own and the
    check cannot drift with how either side hashes a list. The worker's record is
    <AINODE_HOME>/users-sync.json, which holds a stamp, an address and a count,
    and no credential.

Installer

After a protected install, the two lines an operator needs, and nothing at all
when auth is off:

    Login:   ainode auth enable
             ainode auth user add <name> --admin
             [the account is how a human signs in instead of pasting
              the key; --password-stdin where there is no terminal]

One other installer change, because --password-stdin would not have worked
without it: the host wrapper ran every forwarded command with docker exec -it,
so echo pw | ainode auth user add x --password-stdin died on "the input device
is not a TTY" before the CLI in the container ran at all. It now picks -i or
-it from whether stdin and stdout are terminals, which is the same failure
ainode doctor --peer already routes around by calling docker exec itself.
An interactive ainode is unchanged. Nothing else about the install changes.

Tests

pytest tests/ is green (2852 passed). ruff check . is clean.

  • tests/test_auth_replication.py (41): the push carries the fleet key and only
    users; a refused peer is retried and an agreeing one is not; an unreachable
    peer is a failure and not a crash; the worker pull imports and records the
    master's stamp; an unreachable master warns once and imports nothing; the 60
    second retry and the 5 minute pull; roles (a configured worker is never the
    authority, the election outranks two configs that both say master); the CLI
    push over the stdlib; the real create_app startup registers the broadcaster
    and its cleanup cancels the loop.
  • tests/test_cli_auth_users.py (35): the argparse wiring, both password paths
    (two getpass prompts that must agree, and --password-stdin), a short
    password refused before anything is written, the last-admin refusal for both
    remove and disable, replication triggered on a master and warned about on a
    worker, and no session command replicating anything.
  • tests/test_doctor.py: fifteen new cases for the three Login checks, including
    --fix on the file mode and the fleet key on the master's export read.
  • Three installer cases in tests/test_cli_auth_users.py: the two lines printed
    on a protected install, nothing printed with AINODE_AUTH=off, and the
    wrapper's TTY guard.

Changelog text for the release PR

Added

  • ainode auth user add|list|remove|passwd|disable|enable and ainode auth session list|revoke|clear: dashboard accounts and sign-ins from the box. add
    and passwd prompt twice with getpass; --password-stdin is the path where
    there is no TTY, which includes the installer's docker exec -it wrapper under
    ssh and every script. Removing or disabling the last admin is refused,
    because with auth on and no admin the dashboard can only be opened by pasting
    an API key.
  • ainode auth status prints the user, admin and session counts beside the key
    count, and names the fix when a protected node has no account.
  • Account replication (ainode/auth/replication.py): the cluster's master is the
    one authority for accounts. It pushes every change to its peers with the fleet
    key and retries a peer that is behind on a 60 second tick; a worker pulls the
    master's list at startup and every 5 minutes. Sessions are never replicated: a
    session is one browser's credential against one node. A node with no
    cluster_secret, or with no peers, does nothing.
  • ainode doctor gains a Login check: FAIL when a key is required and no
    dashboard account exists, WARN with accounts but no enabled admin, WARN on a
    users.json wider than 0600 (--fix tightens it), and WARN on a worker whose
    account list is older than the master's.
  • The installer prints ainode auth enable and ainode auth user add <name> --admin after an install that requires a credential, and nothing when
    AINODE_AUTH=off.

Fixed

  • The host wrapper the installer writes asks for a TTY only when it has one. It
    ran every forwarded command with docker exec -it, so any piped input (now
    including ainode auth user add --password-stdin) died on "the input device is
    not a TTY" before the CLI in the container ran.

🤖 Generated with Claude Code

webdevtodayjason and others added 4 commits September 21, 2026 19:17
The login gives a node a second credential, a named account with a password
in users.json, and two things have to exist around it before it is usable on
this fleet: a way to make the first account (there is no sign-up page, and
must not be: a route that mints the first admin is a route anybody who can
reach port 3000 calls before the operator does), and one authority for the
account list, because an operator who adds a login on the master and then
opens the dashboard of whichever node a bookmark points at would otherwise be
told their password is wrong.

`ainode auth user add|list|remove|passwd|disable|enable` and
`ainode auth session list|revoke|clear` write the store on the box, which the
running node picks up through reload_if_changed exactly as `ainode auth
enable` is live. Interactive add prompts twice with getpass; --password-stdin
is the path with no TTY, which matters because the installer's host wrapper
runs `docker exec -it` and a script, a unit or `ssh host ainode ...` has no
terminal. Removing or disabling the last admin is refused: with auth on and
no admin the dashboard can only be opened by pasting an API key, which is
what the login exists to replace. `ainode auth status` now counts users,
admins and sessions beside the keys.

ainode/auth/replication.py makes the master the authority. It registers
app["users_changed"], so every account mutation pushes export_users() to
/api/auth/users/sync on every peer with fleet_headers (the export carries
password hashes, so that key is the only one the route takes). A peer that
refused is retried on the next change and on a 60 second tick while it is
behind, which is a comparison and not a queue: the master remembers the stamp
each peer accepted, so a peer that failed simply still disagrees. A worker
pulls /api/auth/users/export at startup and every 5 minutes, because a push
cannot reach a node that was down and a node that has just joined has no
accounts at all. Sessions are never replicated: a session is one browser's
credential against one node.

`ainode doctor` gains a Login check: FAIL when auth is on with no account,
WARN with accounts but no enabled admin, WARN on a users.json wider than
0600 (--fix tightens it), and on a worker WARN when its list is older than
the master's stamp, comparing two of the master's own stamps rather than two
hashes computed by different code. The installer prints the two lines an
operator needs after a protected install, and nothing when auth is off.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Reading order: what the API does without a key, then who can sign in.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`--password-stdin` exists for the case with no terminal, and the wrapper the
installer writes ran every forwarded command with `docker exec -it`, so
`echo pw | ainode auth user add x --password-stdin` died on "the input device
is not a TTY" before the CLI in the container ran at all. That is the same
failure `ainode doctor --peer` already routes around by calling `docker exec`
itself without -it (found against Spark-3). The wrapper now picks -i or -it
from whether stdin and stdout are terminals, which fixes the pipe and leaves
an interactive `ainode` exactly as it was.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…longer has

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@webdevtodayjason
webdevtodayjason merged commit cc0e1b8 into main Sep 22, 2026
1 check passed
@webdevtodayjason
webdevtodayjason deleted the fable/login-cli-fleet branch September 22, 2026 00:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant