fix(nut): log in with the verbs upsd 2.8.5 actually answers to

`NutClient.login()` sent `USER`/`PASS`. The deployed upsd 2.8.5 implements neither —
its own `HELP` advertises `USERNAME`/`PASSWORD` — so each line answered
`ERR UNKNOWN-COMMAND`, `_read_line()` raised, and `POST /api/ups/command` could only
ever 502 before `instcmd()`, credentials or not.

Values go out unquoted: upsd takes the rest of the line literally, so quoting them
would make the quotes part of the credential.

`ERROR_HINTS["ACCESS-DENIED"]` blamed the upsd.conf ACL/LISTEN lines, but that code is
only reachable on an authenticated session (an unauthenticated client gets
`ERR USERNAME-REQUIRED`), so the cause is the account's `instcmds` grant. The hint now
names the one thing the operator can change.

The fake upsd in tests has no `USER`/`PASS` branch, so a regression answers
`ERR UNKNOWN-COMMAND` exactly like the real server, and its `PASSWORD` branch checks the
secret so a wrong-credential refusal is expressible.

LLM-Generated: true
This commit is contained in:
2026-10-17 12:26:47 -04:00
parent 2460a0adf5
commit 1f20a5e57f
2 changed files with 61 additions and 8 deletions
+18 -5
View File
@@ -54,8 +54,13 @@ class NutError(Exception):
#: upsd error tokens -> what the operator should actually do about them.
ERROR_HINTS = {
"ACCESS-DENIED": "upsd refused: this client is not in an ACL that allows it (upsd.conf `ACL`/"
"`LISTEN`, or the user lacks the privilege in upsd.users)",
# Reached only on an *authenticated* session — an unauthenticated client gets
# ERR USERNAME-REQUIRED instead. So the cause is the account's privileges, never the
# upsd.conf ACL/LISTEN lines that used to be blamed here, and the sentence has to name
# the one thing the operator can actually change.
"ACCESS-DENIED": "upsd accepted the login but the account may not send this: an instant "
"command needs its own `instcmds` line in upsd.users (or `instcmds = all`), "
"and nut-server must restart to pick the change up",
"INVALID-PASSWORD": "wrong NUT_PASSWORD for NUT_USER (upsd.users)",
"INVALID-USERNAME": "NUT_USER is not defined in upsd.users",
"INVALID-ARGUMENT": "malformed command or unknown UPS",
@@ -181,12 +186,20 @@ class NutClient:
raise NutError(f"{kind} list too long", code="UNEXPECTED")
async def login(self) -> bool:
"""Authenticate for actions. Returns False when no credentials are configured."""
"""Authenticate for actions. Returns False when no credentials are configured.
`USERNAME` + `PASSWORD`, never `USER`/`PASS`. Captured from the deployed upsd 2.8.5,
whose own `HELP` answers `Commands: HELP VER PROTVER GET LIST SET INSTCMD LOGIN LOGOUT
USERNAME PASSWORD STARTTLS` and replies `ERR UNKNOWN-COMMAND` to both `USER` and
`PASS` — with those verbs the login raised here and `POST /api/ups/command` could only
ever 502, credentials or not. Values go out **unquoted**: upsd takes the rest of the
line literally, so quoting them would make the quotes part of the credential.
"""
if not self.username or not self.password:
return False
await self._send(f"USER {self.username}")
await self._send(f"USERNAME {self.username}")
await self._read_line()
await self._send(f"PASS {self.password}")
await self._send(f"PASSWORD {self.password}")
await self._read_line()
return True