Files
Jean-Pierre MartineauandClaude Opus 5.5 1d983013d7 No log keeps a password or a secret: the log file, the run log, or the remote history
Passwords, channel secrets, pairing PINs, and private keys reached the DEBUG log and
the runs table in plain text: every chat run stored its channel secret inside the
Conversation repr, and --password, --secret, channel links, and config set device_pin
were stored as typed. meshterm.core.secrets now hides each one as <secret> by its
shape (a secret-named key, record, set operation, or dataclass field; bytes; a Secret;
a remote `password`/`set guest.password` command; name=value in text), and by value
for each secret MeshTerm has read or sent. Both log handlers and the runs table go
through it, always. The remote CLI history keeps `password ` without its value. Schema
v20 hides the secrets that older versions stored.

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

263 lines
11 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""The store for the admin state of remote nodes: the last-read settings and the CLI history.
A read of the settings of a repeater costs one paced round trip on the mesh for each value.
Thus the repeater-admin screen never reads all the values when it opens. It shows what the
last read (or the last applied ``set``) said, with its time, and it reads again when the
user asks.
That cache is here, for each node, in a small JSON file in the config directory
(``<config_dir>/remote.json``). The key is the public key of the node, the same as for the
admin passwords. The same file holds the remote command-line history of each node. This is
global machine state, the same as the other JSON stores. It is intentionally outside the
SQLite database, which can be different for each run.
The file is read into typed records (:class:`_RemoteNode`, each with its
:class:`CachedValue` settings) and written back from them. The store never writes back the
raw JSON that it read. Thus a field that no record knows is removed after the next write.
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field, replace
from datetime import datetime
from pathlib import Path
from typing import ClassVar
from .admin_store import admin_key
from .atomicwrite import write_atomically
from .models import Contact, utcnow
from .secrets import names_secret, without_secret
from .secrets import remember as remember_secret
#: How many command-line entries the store keeps for each node (newest last).
HISTORY_CAP = 100
@dataclass(frozen=True, slots=True)
class CachedValue:
"""One remembered remote setting: what the node said last, and when.
Attributes:
value: The value as stored text (empty when the node does not support the
setting).
read_at: When it was read from the node (or written to it).
supported: ``False`` when the node answered the read with an error: its firmware
has no such setting (a board without a front-end module, a build without a
bridge). This is separate from "never read", which is no entry at all.
discovered: ``True`` for a setting that the catalog does not have. MeshTerm
learned it from a command that the user ran on the command line of this node.
When the node no longer answers it, such a row reads ``n/a``, the same as each
other row. Only the user can remove it, by hand, because its key is stored
nowhere else. Thus a reply that MeshTerm reads incorrectly must not be able to
delete it.
"""
value: str
read_at: datetime | None
supported: bool = True
discovered: bool = False
#: The fields that this record held before. It must never hold them again with a
#: different 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, raw: object) -> CachedValue | None:
"""Build a value from one file entry, or ``None`` when the entry is malformed.
A supported entry must have its value. An unsupported entry has no value.
"""
if not isinstance(raw, dict):
return None
supported = not raw.get("unsupported", False)
if supported and "value" not in raw:
return None
return cls(
value=str(raw.get("value", "")),
read_at=_as_time(raw.get("read_at")),
supported=supported,
discovered=bool(raw.get("discovered", False)),
)
def to_json(self) -> dict:
"""The entry as written, in the spelling that the file always used.
A supported value has ``value``. An unsupported value has ``unsupported`` and no
value. ``discovered`` is present only when it is true.
"""
entry: dict = {"value": self.value} if self.supported else {"unsupported": True}
entry["read_at"] = self.read_at.isoformat() if self.read_at is not None else None
if self.discovered:
entry["discovered"] = True
return entry
@dataclass(slots=True)
class _RemoteNode:
"""All that MeshTerm remembers about one remote node.
Attributes:
settings: Its cached settings, with the CLI parameter name as the key.
history: Its remote command-line history, oldest first.
"""
settings: dict[str, CachedValue] = field(default_factory=dict)
history: list[str] = field(default_factory=list)
#: The fields that this record held before. There are none yet (refer to
#: :attr:`CachedValue.RETIRED`).
RETIRED: ClassVar[frozenset[str]] = frozenset()
@classmethod
def read(cls, raw: object) -> _RemoteNode:
"""Build the record of a node from its file entry, with only the fields it knows."""
record = raw if isinstance(raw, dict) else {}
settings: dict[str, CachedValue] = {}
raw_settings = record.get("settings")
if isinstance(raw_settings, dict):
for key, entry in raw_settings.items():
value = CachedValue.read(entry)
if value is not None:
settings[str(key)] = value
raw_history = record.get("history")
# A history that an earlier version wrote can hold a password. The record keeps
# the command without it, so the next write removes it from the file.
history = (
[without_secret(str(c)) for c in raw_history] if isinstance(raw_history, list) else []
)
return cls(settings=settings, history=history)
@property
def empty(self) -> bool:
"""Whether nothing remains to remember about this node."""
return not self.settings and not self.history
def to_json(self) -> dict:
"""The record as written: its own fields only, not the other fields of the read entry."""
return {
"settings": {key: value.to_json() for key, value in self.settings.items()},
"history": list(self.history),
}
class RemoteStore:
"""Reads and writes the remote-admin state of each node (setting cache and CLI history)."""
def __init__(self, path: Path) -> None:
"""Open the store on the location of a JSON file.
Args:
path: Path to the JSON state file (made only at the first write).
"""
self._path = path
def _load(self) -> dict[str, _RemoteNode]:
"""Read the records of all the nodes by :func:`admin_key` (empty at a problem)."""
try:
data = json.loads(self._path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
if not isinstance(data, dict):
return {}
return {str(key): _RemoteNode.read(raw) for key, raw in data.items()}
def _node(self, node: Contact) -> _RemoteNode:
"""The record of one node, or an empty record when nothing is remembered."""
return self._load().get(admin_key(node)) or _RemoteNode()
# -- the settings cache -------------------------------------------------------
def settings(self, node: Contact) -> dict[str, CachedValue]:
"""All the remembered settings for ``node``, with the CLI parameter name as the key."""
return dict(self._node(node).settings)
def remember_setting(self, node: Contact, key: str, value: str) -> None:
"""Cache the value of one setting for ``node``, with the time now.
The value of a setting that is a secret (``guest.password``, ``bridge.secret``) is
also registered, so that no log can repeat it (refer to
:mod:`meshterm.core.secrets`).
"""
if names_secret(key):
remember_secret(value)
self._put(node, key, CachedValue(value=value, read_at=utcnow()))
def remember_discovered(self, node: Contact, key: str, value: str) -> None:
"""Cache a setting that is not in the catalog, which ``node`` proved that it has."""
if names_secret(key):
remember_secret(value)
self._put(node, key, CachedValue(value=value, read_at=utcnow(), discovered=True))
def remember_unsupported(self, node: Contact, key: str) -> None:
"""Store that ``node`` answered a read of ``key`` with an error, with the time now."""
self._put(node, key, CachedValue(value="", read_at=utcnow(), supported=False))
def forget_setting(self, node: Contact, key: str) -> None:
"""Remove one cached setting, so that the row reads as never read."""
records = self._load()
record = records.get(admin_key(node))
if record is not None and record.settings.pop(key, None) is not None:
self._write(records)
def _put(self, node: Contact, key: str, value: CachedValue) -> None:
"""Store the cache entry of one setting for ``node``, and keep the discovered flag.
The same reads and writes as for each other row change a discovered row (through
:meth:`remember_setting`). That change must not quietly make it a catalog row,
because the catalog has no entry for it. If that occurs, nothing draws the row.
"""
records = self._load()
record = records.setdefault(admin_key(node), _RemoteNode())
previous = record.settings.get(key)
if previous is not None and previous.discovered:
value = replace(value, discovered=True)
record.settings[key] = value
self._write(records)
# -- the command-line history ---------------------------------------------------
def history(self, node: Contact) -> list[str]:
"""The remote CLI history of the node, oldest first."""
return list(self._node(node).history)
def append_history(self, node: Contact, command: str) -> None:
"""Add one sent command to the history of the node (not a repeat of the last command).
A command that sets a password or a secret is kept without its value
(:func:`~meshterm.core.secrets.without_secret`): ``password hunter2`` is kept as
``password``, with the space after it. Thus ↑ recalls the start of the command, and
the file never holds the secret.
"""
command = command.strip()
if not command:
return
command = without_secret(command)
records = self._load()
record = records.setdefault(admin_key(node), _RemoteNode())
if record.history and record.history[-1] == command:
return # a repeat of the last command must not add a duplicate to the recall
record.history.append(command)
del record.history[:-HISTORY_CAP]
self._write(records)
def _write(self, records: dict[str, _RemoteNode]) -> None:
"""Write ``records`` atomically.
If a crash occurs during the write, the previous file stays.
"""
data = {key: record.to_json() for key, record in records.items() if not record.empty}
write_atomically(self._path, json.dumps(data, indent=2))
def _as_time(value: object) -> datetime | None:
"""Parse a stored ISO-8601 timestamp, or return ``None`` if it is absent or corrupt."""
if not isinstance(value, str):
return None
try:
return datetime.fromisoformat(value)
except ValueError:
return None