Files
MeshTerm/meshterm/core/admin_store.py
T
Jean-Pierre MartineauandClaude Opus 5.5 6b13755c2b Every store writes only the fields its records know
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>
2026-09-23 22:07:48 -04:00

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)