Login: the operator commands and fleet replication (#261) - #265
Merged
Merged
Conversation
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
force-pushed
the
fable/login-cli-fleet
branch
from
September 22, 2026 00:17
c516cb7 to
353e7fa
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
UsersStorethrough one guarded import, so itlands 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 authparser, andauth key ...,auth status|enable|disablekeep working exactly as they did.ainode auth statusnow prints the accounts half of the same question: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 doctorFAILs on.Replication
ainode/auth/replication.py. The master is the authority for accounts, andsessions 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.
app["users_changed"]is registered here, so everyaccount mutation the routes make POSTs
export_users()to/api/auth/users/syncon every peer discovery knows about, in the background,with
fleet_headers. The export carries password hashes, which is why that keyis the only one the route takes.
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.
/api/auth/users/exportat startup and every 5 minutes. Thepull 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.
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_startupbeside the other background tasks (afterthe 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
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.
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.
WARNfromainode doctor(
login.sync), not a silent no-op.ainode auth user addon 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 addon the master with the service down still replicates:it falls back to the peers
config.jsonnames when/api/cluster/infodoes not answer.Doctor
A
Logincheck, 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 adminexists, 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 whenusers.jsonis wider than 0600, since it holdspassword hashes.
--fixchmods it, like the secrets store.login.sync: on a WORKER only, WARN when its list is older than the master'sstamp. 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:
One other installer change, because
--password-stdinwould not have workedwithout it: the host wrapper ran every forwarded command with
docker exec -it,so
echo pw | ainode auth user add x --password-stdindied on "the input deviceis not a TTY" before the CLI in the container ran at all. It now picks
-ior-itfrom whether stdin and stdout are terminals, which is the same failureainode doctor --peeralready routes around by callingdocker execitself.An interactive
ainodeis 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 onlyusers; a refused peer is retried and an agreeing one is not; an unreachablepeer 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_appstartup registers the broadcasterand its cleanup cancels the loop.
tests/test_cli_auth_users.py(35): the argparse wiring, both password paths(two
getpassprompts that must agree, and--password-stdin), a shortpassword 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--fixon the file mode and the fleet key on the master's export read.tests/test_cli_auth_users.py: the two lines printedon a protected install, nothing printed with
AINODE_AUTH=off, and thewrapper's TTY guard.
Changelog text for the release PR
Added
ainode auth user add|list|remove|passwd|disable|enableandainode auth session list|revoke|clear: dashboard accounts and sign-ins from the box.addand
passwdprompt twice withgetpass;--password-stdinis the path wherethere is no TTY, which includes the installer's
docker exec -itwrapper undersshand 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 statusprints the user, admin and session counts beside the keycount, and names the fix when a protected node has no account.
ainode/auth/replication.py): the cluster's master is theone 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 doctorgains a Login check: FAIL when a key is required and nodashboard account exists, WARN with accounts but no enabled admin, WARN on a
users.jsonwider than 0600 (--fixtightens it), and WARN on a worker whoseaccount list is older than the master's.
ainode auth enableandainode auth user add <name> --adminafter an install that requires a credential, and nothing whenAINODE_AUTH=off.Fixed
ran every forwarded command with
docker exec -it, so any piped input (nowincluding
ainode auth user add --password-stdin) died on "the input device isnot a TTY" before the CLI in the container ran.
🤖 Generated with Claude Code