The admin and remote stores round-tripped the raw JSON they read, so any field a past version wrote survived forever. Both now read into typed records (AdminCredential; _RemoteNode holding CachedValue settings) and write out of them, keeping the file's existing spelling. The settings store keeps only keys device_config still registers: it offers remembered values back to the radio, so an unregistered key must not reach a restore. Every persisted record now declares a RETIRED set (all empty: the history holds no removed fields), device_config gains its own RETIRED registry, and test_retired pins each store's records so the sweep cannot go vacuous. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
173 lines
6.5 KiB
Python
173 lines
6.5 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
"""Persistence for remembered remote-node admin passwords.
|
|
|
|
The remote-admin optimizer logs in to a repeater to tune its TX power; storing the
|
|
password means the user isn't prompted on every run. Like the remembered device
|
|
(:mod:`meshterm.core.device_store`), this lives in a small JSON file in the config
|
|
directory (``<config_dir>/admin.json``) rather than the per-invocation SQLite database,
|
|
because admin credentials are global machine state.
|
|
|
|
The file is read into :class:`AdminCredential` records and written back out of them,
|
|
never round-tripped as the raw JSON it was read as, so a field no record knows is gone
|
|
after the next write (see :data:`AdminCredential.RETIRED`).
|
|
|
|
Passwords are stored in plaintext, so the file is written with owner-only permissions
|
|
where the platform supports it. This is a local operator tool tuning their own mesh, not
|
|
a multi-user secret store; treat the file accordingly.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import ClassVar
|
|
|
|
from .atomicwrite import write_atomically
|
|
from .models import Contact, LoginResult, utcnow
|
|
|
|
|
|
def admin_key(node: Contact) -> str:
|
|
"""Return the lookup key for a node's stored password.
|
|
|
|
Prefers the full public key (stable and unambiguous); falls back to the key prefix
|
|
and finally the name so a contact without a key can still be remembered.
|
|
|
|
Args:
|
|
node: The contact to key on.
|
|
|
|
Returns:
|
|
A normalized, lowercased identifier.
|
|
"""
|
|
key = node.public_key or node.key_prefix or node.name
|
|
return key.lower().removeprefix("0x")
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class AdminCredential:
|
|
"""A remembered admin password for one remote node.
|
|
|
|
Attributes:
|
|
key: The :func:`admin_key` the password is stored under (the record's key in the
|
|
file, not one of its fields).
|
|
password: The node's admin password (plaintext).
|
|
label: A friendly node name for display.
|
|
last_used: ISO-8601 timestamp of the last successful login.
|
|
"""
|
|
|
|
key: str
|
|
password: str
|
|
label: str
|
|
last_used: str
|
|
|
|
#: Fields this record once held and must never hold again under another meaning (see
|
|
#: :data:`meshterm.core.preferences.RETIRED` for why a name is never reused).
|
|
RETIRED: ClassVar[frozenset[str]] = frozenset()
|
|
|
|
@classmethod
|
|
def read(cls, key: str, raw: object) -> AdminCredential | None:
|
|
"""Build a record from one file entry, or ``None`` when it holds no password."""
|
|
if not isinstance(raw, dict) or raw.get("password") is None:
|
|
return None
|
|
return cls(
|
|
key=key,
|
|
password=str(raw["password"]),
|
|
label=str(raw.get("label") or ""),
|
|
last_used=str(raw.get("last_used") or ""),
|
|
)
|
|
|
|
def to_json(self) -> dict:
|
|
"""The record as written: its own fields and nothing it happened to be read with."""
|
|
return {"password": self.password, "label": self.label, "last_used": self.last_used}
|
|
|
|
|
|
class AdminStore:
|
|
"""Reads and writes remembered remote-node admin passwords."""
|
|
|
|
def __init__(self, path: Path) -> None:
|
|
"""Open the store against a JSON file location.
|
|
|
|
Args:
|
|
path: Path to the JSON state file (created lazily on first write).
|
|
"""
|
|
self._path = path
|
|
|
|
def _load(self) -> dict[str, AdminCredential]:
|
|
"""Read every credential, keyed by :func:`admin_key`; empty on a missing/corrupt file."""
|
|
try:
|
|
data = json.loads(self._path.read_text(encoding="utf-8"))
|
|
except (OSError, ValueError):
|
|
return {}
|
|
if not isinstance(data, dict):
|
|
return {}
|
|
records = {}
|
|
for key, raw in data.items():
|
|
record = AdminCredential.read(str(key), raw)
|
|
if record is not None:
|
|
records[record.key] = record
|
|
return records
|
|
|
|
def get(self, node: Contact) -> str | None:
|
|
"""Return the remembered password for ``node``, or ``None`` if not stored.
|
|
|
|
Args:
|
|
node: The contact to look up.
|
|
|
|
Returns:
|
|
The stored password, or ``None``.
|
|
"""
|
|
record = self._load().get(admin_key(node))
|
|
return record.password if record is not None else None
|
|
|
|
def remember(self, node: Contact, password: str) -> None:
|
|
"""Store (or update) the admin password for ``node``.
|
|
|
|
Args:
|
|
node: The contact the password belongs to.
|
|
password: The admin password to remember.
|
|
"""
|
|
records = self._load()
|
|
key = admin_key(node)
|
|
records[key] = AdminCredential(
|
|
key=key, password=password, label=node.name, last_used=utcnow().isoformat()
|
|
)
|
|
self._write(records)
|
|
|
|
def record(self, node: Contact, password: str, outcome: LoginResult) -> None:
|
|
"""Update what we remember about ``node`` from how a login attempt ended.
|
|
|
|
THE credential policy, in one place because it was wrong in five: a password is
|
|
remembered when the node accepts it and forgotten when the node *rejects* it — and
|
|
left exactly as it is when nothing came back. Silence is not a denial. Every caller
|
|
used to collapse "refused" and "no reply" into one ``False`` and forget on both, so
|
|
opening the admin flow on a repeater that happened to be down erased its password;
|
|
the node had said nothing at all, and we took the silence as a verdict.
|
|
|
|
Callers still phrase their own message (the sweep says "sweep again", the CLI says
|
|
"re-run"), but none of them decides this.
|
|
|
|
Args:
|
|
node: The contact the attempt addressed.
|
|
password: The password that was tried.
|
|
outcome: What the device reported.
|
|
"""
|
|
if outcome is LoginResult.ACCEPTED:
|
|
self.remember(node, password)
|
|
elif outcome is LoginResult.REFUSED:
|
|
self.forget(node)
|
|
|
|
def forget(self, node: Contact) -> None:
|
|
"""Remove any remembered password for ``node`` (e.g. after it stops working).
|
|
|
|
Args:
|
|
node: The contact to forget.
|
|
"""
|
|
records = self._load()
|
|
if records.pop(admin_key(node), None) is not None:
|
|
self._write(records)
|
|
|
|
def _write(self, records: dict[str, AdminCredential]) -> None:
|
|
"""Persist ``records`` atomically with owner-only permissions where supported."""
|
|
data = {key: record.to_json() for key, record in records.items()}
|
|
write_atomically(self._path, json.dumps(data, indent=2), owner_only=True)
|