Every copy on a machine reads and writes the same ~/.meshterm — a checkout, a downloaded build, a second one for a second radio — and nothing about how it was installed changes that. Two at once will not corrupt anything, but each holds the JSON stores in memory and rewrites them whole, so the second to save discards the first's changes. A lost contact is much harder to notice than a crash. The interactive session now takes an exclusive lock on the directory it is using, and a second one is refused with the fix in the message: set MESHTERM_HOME. The lock belongs to the operating system rather than to a process id written in a file, because a pid file has to answer "is that still alive?", the answer differs per platform, and it is wrong once a reboot recycles the number — so a crash leaves a lock nobody can explain and everyone learns to delete it. One-shot subcommands do not take it; blocking `meshterm contacts` because a menu is open in another window would be an obstacle rather than a guard. The scratch-file race is fixed at the same time, and it was ten places rather than the three I first counted: every store under core/ carried the same three lines, writing to a fixed `.tmp` name beside its file. Two processes saving together took turns inside that one scratch file and then each renamed whatever was in it over the real one — the rename is atomic, the thing being renamed was not. They now go through one `write_atomically`, whose temporary carries the writing process's id, and which cleans up after a failed write instead of leaving a partial file behind. And the Windows console error is answered rather than raised. prompt_toolkit needs a Win32 console; Git Bash, MSYS and Cygwin are not one, and its complaint arrives as a traceback that reads like a broken application when the answer is "open a different terminal". It now says that, and points out that every screen has a subcommand which pipes perfectly well. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
142 lines
5.0 KiB
Python
142 lines
5.0 KiB
Python
"""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.
|
|
|
|
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 .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.
|
|
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
|
|
|
|
|
|
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_all(self) -> dict[str, dict]:
|
|
"""Return the raw key -> record mapping, or empty on missing/corrupt file."""
|
|
try:
|
|
data = json.loads(self._path.read_text(encoding="utf-8"))
|
|
except (OSError, ValueError):
|
|
return {}
|
|
return data if isinstance(data, dict) else {}
|
|
|
|
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_all().get(admin_key(node))
|
|
if isinstance(record, dict):
|
|
password = record.get("password")
|
|
return str(password) if password is not None else None
|
|
return 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_all()
|
|
records[admin_key(node)] = {
|
|
"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_all()
|
|
if records.pop(admin_key(node), None) is not None:
|
|
self._write(records)
|
|
|
|
def _write(self, records: dict[str, dict]) -> None:
|
|
"""Persist ``records`` atomically with owner-only permissions where supported."""
|
|
write_atomically(self._path, json.dumps(records, indent=2), owner_only=True)
|