Files
Jean-Pierre MartineauandClaude Opus 5.5 ffe31dac36 Docstrings and comments of meshterm/core are in ASD-STE100
The prose of each docstring and comment in the core package now follows
ASD-STE100 Simplified Technical English, with Canadian spelling: short
sentences, simple verb tenses, no dashes or semicolons, and one word for
one concept (docs/development/writing-style.md has the rules and the
glossary). The meaning and each reason stay. The code does not change:
the AST of each file, without its docstrings, is the same as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 03:29:03 -04:00

184 lines
7.1 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""Storage for the admin passwords of remote nodes that MeshTerm remembers.
The remote-admin optimizer logs in to a repeater to adjust its TX power. When MeshTerm
stores the password, the user does not get a prompt at each run. This store is a small
JSON file in the config directory (``<config_dir>/admin.json``), the same as the
remembered device (:mod:`meshterm.core.device_store`). It is not in the SQLite database
of each invocation, because admin credentials are global state of the machine.
The store reads the file into :class:`AdminCredential` records, and it writes the file
from these records. It never writes back the raw JSON that it read. Thus a field that no
record knows is gone after the next write (refer to :data:`AdminCredential.RETIRED`).
The passwords are stored in plaintext. Thus the store writes the file with owner-only
permissions where the platform supports them. This store is a local tool for a user who
adjusts their own mesh. It is not a secret store for many users. Handle the file as a
local file that contains plaintext passwords.
"""
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 the stored password of a node.
The function uses the full public key first, because it is stable and has only one
meaning. If there is no public key, it uses the key prefix, and then the name. Thus
MeshTerm can also remember a contact that has no key.
Args:
node: The contact for the lookup key.
Returns:
A normalized identifier in lower case.
"""
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` under which the password is stored. It is the key of
the record in the file, not one of its fields.
password: The admin password of the node (plaintext).
label: A node name to show to the user.
last_used: The ISO-8601 timestamp of the last successful login.
"""
key: str
password: str
label: str
last_used: str
#: The fields that this record held before. It must never hold them again with another
#: meaning (refer to :data:`meshterm.core.preferences.RETIRED` for the reason that a
#: name is never used again).
RETIRED: ClassVar[frozenset[str]] = frozenset()
@classmethod
def read(cls, key: str, raw: object) -> AdminCredential | None:
"""Make a record from one file entry, or ``None`` when the entry has 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 the store writes it: its own fields, and no other data read with it."""
return {"password": self.password, "label": self.label, "last_used": self.last_used}
class AdminStore:
"""Reads and writes the admin passwords of remote nodes that MeshTerm remembers."""
def __init__(self, path: Path) -> None:
"""Open the store on the location of a JSON file.
Args:
path: The path to the JSON state file. The store makes the file at the first
write.
"""
self._path = path
def _load(self) -> dict[str, AdminCredential]:
"""Read all the credentials, by :func:`admin_key`.
The result is empty if the file is missing or corrupt.
"""
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 it is not stored.
Args:
node: The contact to find.
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 change) the admin password for ``node``.
Args:
node: The contact that the password is for.
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:
"""Change what MeshTerm remembers about ``node``, from the result of a login.
This method is the only credential policy. It is in one place because it was wrong
in five places. The store remembers a password when the node accepts it, and it
forgets the password when the node rejects it. When no reply came back, the store
keeps the password exactly as it is. No reply is not a refusal. Before, each caller
changed "refused" and "no reply" into the same ``False``, and forgot the password
in both cases. Thus, when the user opened the admin flow on a repeater that was
down at that time, MeshTerm deleted its password. The node sent nothing at all, and
MeshTerm read that silence as a refusal.
The callers still write their own messages (the sweep says "sweep again", the CLI
says "re-run"), but no caller makes this decision.
Args:
node: The contact that the login was for.
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:
"""Delete the remembered password for ``node``, if there is one.
For example, use this method after the password 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:
"""Store ``records`` atomically, with owner-only permissions where they are supported."""
data = {key: record.to_json() for key, record in records.items()}
write_atomically(self._path, json.dumps(data, indent=2), owner_only=True)