diff --git a/.env.example b/.env.example index 4c15e4b8..4851ffbe 100644 --- a/.env.example +++ b/.env.example @@ -36,13 +36,11 @@ PROTOCOL="meshtastic" # only if the ingestor should read a different RNS config. # RETICULUM_CONFIG_DIR="/home/you/.reticulum" -# Restrict announce ingestion to named RNS interfaces (comma-separated, -# case-insensitive substring match, e.g. "rnode"). An RNS stack may carry LoRa -# and IP interfaces at once, and RNS's default config enables an AutoInterface -# (IPv6 link-local multicast) — leaving this empty ingests every announce -# reachable over any interface, which on a LAN means the whole local Reticulum -# network. Empty is the default. -# RETICULUM_INTERFACES="rnode" +# Which RNS interfaces PROTOCOL=reticulum ingests from. Unset: RNode +# interfaces only. *: every interface (TCP, Backbone, AutoInterface). +# Otherwise comma-separated, case-insensitive substrings of the names in +# rnstatus. Your own nodes are always ingested. +# RETICULUM_INTERFACES="*" # INGESTOR_NODE_ID (see below) is an OVERRIDE here, not a requirement. The # ingestor derives its !xxxxxxxx from your primary identity -- the one announcing diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 2b5eac08..5cacb615 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -28,6 +28,8 @@ on: - 'README.md' - 'docker-compose.yml' - 'flake.nix' + # tests/test_configure_script.py runs it. + - 'configure.sh' - 'web/lib/potato_mesh/config.rb' # tests/test_dockerfile_sync.py compares these two files. - 'Dockerfile' diff --git a/CHANGELOG.md b/CHANGELOG.md index e60048f9..6e79f877 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,8 @@ * Data/Web: one Reticulum node row per announced destination, grouped by `nodes.identity_hash` (SPEC RE1) * Data/Web: `user.publicKey` carries the identity's real public key; destinations get their own table, served by `GET /api/destinations`, which honors the node opt-out marker (SPEC RE2) * Data: new `RETICULUM_INTERFACES` allowlist scopes announce ingestion by interface, applied from one hop out; the operator's own nodes are always ingested (SPEC RN4/RE4) +* Data: `PROTOCOL=reticulum` ingests only RNode interfaces by default; set `RETICULUM_INTERFACES=*` to ingest every interface as before (SPEC RN4) +* Config: `configure.sh` asks for `INGESTOR_NODE_ID` under `PROTOCOL=reticulum` (SPEC RE8) * Web: the `/charts` mesh-activity figure draws the Reticulum series (SPEC RN2) * Web: `instances.reticulum_nodes_count` gets its own migration (SPEC RN7) * Docs: every environment variable the ingestor or web app reads is documented (SPEC DOC2) diff --git a/README.md b/README.md index 425304ea..536bf79c 100644 --- a/README.md +++ b/README.md @@ -295,7 +295,7 @@ Configure with the environment variables below. | `RETICULUM_CONFIG_DIR` | `~/.reticulum` | Which RNS config the ingestor uses, and so which interfaces it can see. Point it at the directory your `rnsd` uses. See [Reticulum](#reticulum). | | `RETICULUM_FREQ` | _from RNS config_ | Frequency shown for Reticulum nodes. Overrides the value read from your `RNodeInterface` section. | | `RETICULUM_PRESET` | _from RNS config_ | Radio preset shown for Reticulum nodes. Overrides the value derived from your bandwidth/spreading-factor/coding-rate. | -| `RETICULUM_INTERFACES` | _unset_ | Which RNS interfaces to ingest from, e.g. `RNode`. Comma-separated, case-insensitive substring match against the names in `rnstatus`. Your own nodes are always ingested; empty ingests everything. See [Reticulum](#reticulum). | +| `RETICULUM_INTERFACES` | _unset_ | Which RNS interfaces to ingest from. Unset: RNode interfaces only. `*`: every interface. Otherwise comma-separated, case-insensitive substrings of the names in `rnstatus`, e.g. `RNode`. Your own nodes are always ingested. See [Reticulum](#reticulum). | | `MESHCORE_TELEMETRY_POLL_SECONDS` | `300` | Requires `TX_ENABLED=1` (polling other nodes is a transmission). Seconds between Meshcore contact telemetry polls (one on-air request per interval, round-robin over the roster; each contact is additionally polled at most once per 24 h: when every contact is fresh the tick sends nothing). Set `0` to disable on-air polling. | | `MESHCORE_TELEMETRY_POLL_24H_EXEMPT` | _unset_ | Comma-separated node ids exempt from the once-per-24 h cap; listed nodes are polled every round-robin rotation (still one request per interval). | | `MESHCORE_SELF_TELEMETRY_SECONDS` | `3600` | Seconds between Meshcore host self-telemetry reads (battery/sensors over the companion link, no airtime). Set `0` to disable. | @@ -363,13 +363,17 @@ listens for announces and files each one as a node. It never transmits, so `CONNECTION` does not apply; if set, the ingestor ignores it and logs that it did. -It reads your existing `~/.reticulum`, so if `rnsd` already works, so does this: +It reads your existing `~/.reticulum` and ingests what your RNode interfaces +hear: ```bash API_TOKEN=... INSTANCE_DOMAIN=https://your.instance PROTOCOL=reticulum ./data/mesh.sh ``` -To ingest only from your radio, set `RETICULUM_INTERFACES` to part of the +To also ingest TCP, Backbone and `AutoInterface` peers, set +`RETICULUM_INTERFACES=*`. + +To pick interfaces by name, set `RETICULUM_INTERFACES` to part of each interface name from `rnstatus` - matching is case-insensitive substring: ```bash @@ -377,7 +381,8 @@ RETICULUM_INTERFACES="RNode Reticulum Berlin" ``` Your own nodes are always ingested regardless of this setting. Everything -further away is filtered by it. +further away is filtered by it. With no RNode on the stack and the variable +unset, only your own nodes are ingested and the ingestor logs a warning. To use a different RNS config, set `RETICULUM_CONFIG_DIR`, pointing at the same directory your `rnsd` uses - interface filtering fails across mismatched @@ -385,10 +390,9 @@ directories. In Docker, the config dir is the `potatomesh_reticulum` volume (`/app/.config/potato-mesh/reticulum`). RNS seeds a stock config there with -only a link-local `AutoInterface`, which reaches no radio on the default -bridge network: +only a link-local `AutoInterface` and no RNode, so nothing is ingested from it: -- Add your interfaces to the volume's `config` file, then restart: +- Add your RNode interface to the volume's `config` file, then restart: `docker compose exec ingestor sh -c 'cat >> /app/.config/potato-mesh/reticulum/config'` - Or point `RETICULUM_CONFIG_DIR` at a bind mount of the host's `~/.reticulum` and run with host networking. diff --git a/configure.sh b/configure.sh index d3718742..cf8c837d 100755 --- a/configure.sh +++ b/configure.sh @@ -87,6 +87,7 @@ RETICULUM_CONFIG_DIR=$(grep "^RETICULUM_CONFIG_DIR=" .env 2>/dev/null | cut -d'= RETICULUM_INTERFACES=$(grep "^RETICULUM_INTERFACES=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "") RETICULUM_FREQ=$(grep "^RETICULUM_FREQ=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "") RETICULUM_PRESET=$(grep "^RETICULUM_PRESET=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "") +INGESTOR_NODE_ID=$(grep "^INGESTOR_NODE_ID=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "") FEDERATION=$(grep "^FEDERATION=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "1") PRIVATE=$(grep "^PRIVATE=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "0") HIDDEN_CHANNELS=$(grep "^HIDDEN_CHANNELS=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "") @@ -178,12 +179,28 @@ echo "🔌 Ingestor Connection" echo "----------------------" if [ "$PROTOCOL" = "reticulum" ]; then echo "Reticulum uses an RNS config directory, not a single endpoint." - echo "Leave the directory blank to use ~/.reticulum. Point it at the same" - echo "directory your rnsd uses so interface filtering can resolve names." - read_with_default "RNS config directory (blank = ~/.reticulum)" "$RETICULUM_CONFIG_DIR" RETICULUM_CONFIG_DIR - echo "Restrict ingestion to named interfaces (case-insensitive substring)." - echo "Leave blank to ingest announces from every interface." - read_with_default "Reticulum interfaces" "$RETICULUM_INTERFACES" RETICULUM_INTERFACES + echo "Leave the directory blank to use the potatomesh_reticulum volume. To share" + echo "the host's rnsd instead, see the Reticulum section of README.md." + read_with_default "RNS config directory (blank = potatomesh_reticulum volume)" "$RETICULUM_CONFIG_DIR" RETICULUM_CONFIG_DIR + echo "Required in Docker." + echo "Enter ! plus the first 8 hex of your primary identity hash, or the full 32-hex hash." + NODE_ID_PATTERN='^(![0-9a-fA-F]{8}|[0-9a-fA-F]{32})$' + # Offer the current value only when it is valid, so a blank answer keeps it. + NODE_ID_DEFAULT="" + if [[ "$INGESTOR_NODE_ID" =~ $NODE_ID_PATTERN ]]; then + NODE_ID_DEFAULT="$INGESTOR_NODE_ID" + fi + # Ask until the answer is valid; at end of input, read fails and set -e exits. + while :; do + read_with_default "Ingestor node id" "$NODE_ID_DEFAULT" INGESTOR_NODE_ID + if [[ "$INGESTOR_NODE_ID" =~ $NODE_ID_PATTERN ]]; then + break + fi + echo "Not a valid node id. Enter ! plus 8 hex, or the 32-hex identity hash." + done + echo "Leave interfaces blank to ingest RNode interfaces only. Enter * for every" + echo "interface, or names to match (case-insensitive substring)." + read_with_default "Reticulum interfaces (blank = RNode only, * = all)" "$RETICULUM_INTERFACES" RETICULUM_INTERFACES echo "Frequency and preset are read from your RNS config; leave blank unless" echo "you want to override what the dashboard displays." read_with_default "Reticulum frequency (blank = from RNS config)" "$RETICULUM_FREQ" RETICULUM_FREQ @@ -284,6 +301,9 @@ if [ "$PROTOCOL" = "reticulum" ]; then # Reticulum has no single endpoint; drop any inherited serial default so the # ingestor does not log an ignored-CONNECTION warning on every start. sed -i.bak '/^CONNECTION=.*/d' .env + # Only the reticulum branch asks for it; other runs keep the value as is, + # because the Meshtastic UDP transport reads it too. + update_env_optional "INGESTOR_NODE_ID" "$INGESTOR_NODE_ID" else update_env "CONNECTION" "$CONNECTION" fi @@ -333,8 +353,9 @@ case "$PROTOCOL" in echo " Frequency: ${MESHCORE_FREQ:-'Not set'}" ;; reticulum) - echo " RNS Config Dir: ${RETICULUM_CONFIG_DIR:-'~/.reticulum'}" - echo " Interfaces: ${RETICULUM_INTERFACES:-'All'}" + echo " RNS Config Dir: ${RETICULUM_CONFIG_DIR:-'potatomesh_reticulum volume'}" + echo " Ingestor Node ID: ${INGESTOR_NODE_ID}" + echo " Interfaces: ${RETICULUM_INTERFACES:-'RNode only'}" echo " Frequency: ${RETICULUM_FREQ:-'From RNS config'}" echo " Preset: ${RETICULUM_PRESET:-'From RNS config'}" ;; diff --git a/data/mesh_ingestor/CONTRACTS.md b/data/mesh_ingestor/CONTRACTS.md index 569707ca..e1a26f9f 100644 --- a/data/mesh_ingestor/CONTRACTS.md +++ b/data/mesh_ingestor/CONTRACTS.md @@ -33,7 +33,7 @@ The Reticulum provider (`PROTOCOL=reticulum`, `data/mesh_ingestor/protocols/reti - `identityHash` is the announcing identity's 16-byte hash. - `user.shortName` = the first 4 hex chars of the node id; `user.longName` = the display name decoded from announce `app_data`, falling back to `"Reticulum "` -- the protocol label plus the upper-cased last four hex of the canonical id, which is the exact form the web upsert recognises as a placeholder and therefore refuses to overwrite a real name with. -Scope. `hops == 0` means the announce came from an app on this machine (`Transport.inbound` adds a hop to every inbound packet and takes it back for a local-client or shared-instance interface), so those are always ingested -- the operator's own nodes must never be hidden by a filter. From one hop out, `RETICULUM_INTERFACES` applies as a case-insensitive substring match on the interface name; empty ingests everything. +Scope. `hops == 0` means the announce came from an app on this machine (`Transport.inbound` adds a hop to every inbound packet and takes it back for a local-client or shared-instance interface), so those are always ingested -- the operator's own nodes must never be hidden by a filter. From one hop out, `RETICULUM_INTERFACES` applies: unset admits an RNode interface only, `*` admits every interface, and a list is a case-insensitive substring match on the interface name (see "Interface scope" below). Known limitation. The accumulator is in-memory and per-process, and the web upsert writes `role=COALESCE(excluded.role, nodes.role)`, so an ingestor restart @@ -50,7 +50,7 @@ exposes transport status, and the sender-side determinism rule above (SPEC RD4) rules out inferring it from the ingestor's own path table. A provider for another protocol must not emit `TRANSPORT` for anything but its own host. -Interface scope. An RNS stack can carry LoRa and IP interfaces at once, and an announce listener hears every announce reachable over any of them - with RNS's default `AutoInterface` (IPv6 link-local multicast) that is the entire local Reticulum network. `RETICULUM_INTERFACES` is a comma-separated, case-insensitive substring allowlist of interface names (e.g. `rnode`) matched against the interface the announce's path arrived on; empty is the default and ingests everything. A filtered-out announce is not counted as this mesh's traffic. Reticulum exposes no protocol-level "this peer is on LoRa" marker, so the interface a path arrived on is the only available proxy (SPEC RN4). +Interface scope. An RNS stack can carry LoRa and IP interfaces at once, and an announce listener hears every announce reachable over any of them - with RNS's default `AutoInterface` (IPv6 link-local multicast) that is the entire local Reticulum network. `RETICULUM_INTERFACES` scopes ingestion by the interface the announce's path arrived on. Unset, blank or quote-only (the default) admits an RNode only: an interface whose class in `get_interface_stats()`, read through the shared instance like the name, is `RNodeInterface`, `RNodeMultiInterface` or `RNodeSubInterface`. A sub-interface prints as `[]`, with no "rnode" in it, and is matched by that name against the stats entries; the map is cached and re-read on a miss at most every 30 s. When the stats cannot be read, or do not list the interface, the name must contain `rnode`. A stack listing no RNode keeps only 0-hop announces and the host's own destinations, and warns once per connect. `*` admits every interface. Any other value is a comma-separated, case-insensitive substring allowlist of interface names (e.g. `rnode`). A filtered-out announce is not counted as this mesh's traffic. Reticulum exposes no protocol-level "this peer is on LoRa" marker, so the interface a path arrived on is the only available proxy (SPEC RN4). Config isolation. `RETICULUM_CONFIG_DIR` defaults to an app-owned directory (`$XDG_CONFIG_HOME/potato-mesh/reticulum`, else `~/.config/potato-mesh/reticulum`) and never to RNS's user default `~/.reticulum`: installing a dashboard ingestor does not imply consent to run the operator's transport-node configuration (SPEC RN3). diff --git a/data/mesh_ingestor/config.py b/data/mesh_ingestor/config.py index 20b98007..1ded5c5f 100644 --- a/data/mesh_ingestor/config.py +++ b/data/mesh_ingestor/config.py @@ -225,8 +225,9 @@ def _clean_env_fragment(value: str) -> str: not as shell, so an unset variable arrives as the two-character string ``""``. Read as content that is one entry, and for a filter it is an entry nothing can match — which silently dropped every message on a stock - containerised deployment. Resolving it to empty means the filter is simply - off, which is the documented default. + containerised deployment. Resolving it to empty selects the documented + default: no filter for the channel lists, the RNode scope for + :data:`RETICULUM_INTERFACES`. Quotes are deliberately **not** stripped from a fragment that has other content. These fragments are matched against values the ingestor did not @@ -247,36 +248,51 @@ def _clean_env_fragment(value: str) -> str: return "" if not trimmed.strip("\"'").strip() else trimmed +RETICULUM_ALL_INTERFACES = "*" +"""The :envvar:`RETICULUM_INTERFACES` entry that admits every interface (SPEC RN4).""" + + def _parse_reticulum_interfaces(raw: str) -> tuple[str, ...]: - """Parse the :envvar:`RETICULUM_INTERFACES` allowlist. + """Parse the :envvar:`RETICULUM_INTERFACES` interface scope. Parameters: raw: Comma-separated interface-name fragments, e.g. - ``"RNodeInterface,rnode lora"``. + ``"RNodeInterface,rnode lora"``, or ``*``. Returns: - Tuple of lowercased, de-duplicated, non-empty fragments in a stable - order. Empty when the variable is unset, blank, or quote-only (see - :func:`_clean_env_fragment`). + ``()`` when the variable is unset, blank, or quote-only (see + :func:`_clean_env_fragment`), which selects the RNode default. + ``("*",)`` when any entry is :data:`RETICULUM_ALL_INTERFACES`. + Otherwise the lowercased, de-duplicated, non-empty fragments in a + stable order. """ fragments = {_clean_env_fragment(part).lower() for part in raw.split(",")} - return tuple(sorted(fragments - {""})) + fragments.discard("") + if RETICULUM_ALL_INTERFACES in fragments: + # Every interface already covers the rest of the list. + return (RETICULUM_ALL_INTERFACES,) + return tuple(sorted(fragments)) RETICULUM_INTERFACES = _parse_reticulum_interfaces( os.environ.get("RETICULUM_INTERFACES", "") ) -"""Case-insensitive substring allowlist of RNS interfaces to ingest from. +"""Which RNS interfaces ``PROTOCOL=reticulum`` ingests announces from (SPEC RN4). A Reticulum stack can carry LoRa and IP interfaces at once, and an announce listener hears every announce reachable over *any* of them. On a LAN with an ``AutoInterface`` that means the whole local Reticulum network lands in the -dashboard. Setting :envvar:`RETICULUM_INTERFACES` restricts ingestion to -announces whose path was received on a matching interface (matched as a -lowercased substring of the interface's string form, e.g. ``rnode``). +dashboard. -**Empty (the default) ingests every interface**, preserving the behaviour the -provider shipped with (#888).""" +``()``, the default, admits an announce only when its path arrived on an +RNode, recognised by interface class (SPEC RN4, amended 2026-10-05). Unset, +blank and quote-only all parse to it, which is what Compose's +``${RETICULUM_INTERFACES:-}``, both ``data/Dockerfile`` stages and a ``null`` +NixOS option deliver. ``("*",)`` admits every interface, the behaviour the +provider shipped with (#888). Any other value is a case-insensitive substring +allowlist matched against the interface's string form, e.g. ``rnode``. A +0-hop announce, which comes from this machine, is admitted whatever the scope +(SPEC RE4).""" RETICULUM_FREQ: str | None = ( _clean_env_fragment(os.environ.get("RETICULUM_FREQ", "")) or None @@ -774,6 +790,7 @@ __all__ = [ "MESH_UDP_GROUPS", "MESH_UDP_PORT", "INGESTOR_NODE_ID", + "RETICULUM_ALL_INTERFACES", "RETICULUM_CONFIG_DIR", "RETICULUM_FREQ", "RETICULUM_INTERFACES", diff --git a/data/mesh_ingestor/protocols/reticulum.py b/data/mesh_ingestor/protocols/reticulum.py index bbd8e679..5b4d446d 100644 --- a/data/mesh_ingestor/protocols/reticulum.py +++ b/data/mesh_ingestor/protocols/reticulum.py @@ -73,8 +73,9 @@ cross-ingestor dedup requirement). and this listener hears every announce reachable over any of them — on a LAN with an ``AutoInterface`` that is the entire local Reticulum network. :data:`~data.mesh_ingestor.config.RETICULUM_INTERFACES` restricts ingestion to -announces whose path was received on a matching interface; it is empty by -default, which ingests everything. +announces whose path was received on a matching interface. Unset, it admits +RNode interfaces only, recognised by their RNS class (:func:`_is_rnode_interface`); +``*`` admits every interface. **Transmit policy (SPEC MA7).** This provider is receive-only: it never sends an announce, a message, or a poll, so it has no transmit site to gate. Note @@ -106,6 +107,7 @@ import RNS from RNS.vendor import umsgpack from .. import config, handlers +from . import reticulum_interfaces _ASPECT_ROLES: dict[str, str] = { "lxmf.propagation": "PROPAGATION", @@ -389,6 +391,100 @@ def _announce_interface_name(dest_hash: object) -> str | None: return None +def _running_instance() -> object | None: + """Return the running :class:`RNS.Reticulum` for the interface-class map. + + Resolved via the module-level ``RNS`` name at call time, so test fakes + installed with ``monkeypatch.setattr(_mod, "RNS", ...)`` apply. + + Returns: + The instance, or ``None`` when no stack runs. + """ + return RNS.Reticulum.get_instance() + + +_INTERFACE_CLASSES = reticulum_interfaces.InterfaceClassCache(_running_instance) +"""Interface classes of the running stack, read for the RNode default (RN4).""" + + +def _is_rnode_interface(interface_name: str) -> bool: + """Report whether an interface drives an RNode radio (SPEC RN4, amended). + + The class decides, read from ``get_interface_stats()``: a multi-radio + RNode receives on sub-interfaces printed as ``[]``, with no + "rnode" in the name. When the stack cannot say (no stats, a failed read, + or a name it does not list) the name decides instead, since a single-radio + RNode prints as ``RNodeInterface[...]``. + + Parameters: + interface_name: Interface an announce arrived on. + + Returns: + ``True`` for an ``RNodeInterface``, ``RNodeMultiInterface`` or + ``RNodeSubInterface``. + """ + kind = _INTERFACE_CLASSES.class_of(interface_name) + if kind is None: + return "rnode" in interface_name.lower() + return kind in reticulum_interfaces.RNODE_INTERFACE_CLASSES + + +def _interface_scope() -> str | list[str]: + """Describe the active interface scope for the logs (SPEC RN4). + + Returns: + ``"rnode"`` for the default, ``"*"`` for every interface, or the + allowlist's fragments. + """ + allowlist = config.RETICULUM_INTERFACES + if not allowlist: + return "rnode" + if config.RETICULUM_ALL_INTERFACES in allowlist: + return config.RETICULUM_ALL_INTERFACES + return list(allowlist) + + +def _check_rnode_scope() -> None: + """Warn once per connect when the RNode default can only ingest 0 hops. + + Runs for the default scope only: a list or ``*`` is the operator's own + choice. A stack listing no RNode keeps this machine's announces alone, + and the scope never widens to every interface by itself. A stack that + rejects the stats RPC (a shared instance authenticates it with the config + dir's identity, SPEC RE3) rejects the name lookup as well, so every + announce reads ``LocalInterface[...]`` and is dropped. A stack without + stats at all stays silent, because names then decide. + """ + if config.RETICULUM_INTERFACES: + return + try: + classes = _INTERFACE_CLASSES.read() + except Exception as exc: + config._debug_log( + "Cannot read this RNS stack's interfaces, so announces from beyond " + "this machine read LocalInterface and are dropped; point " + "RETICULUM_CONFIG_DIR at the config dir rnsd uses, or set " + "RETICULUM_INTERFACES=*", + context="reticulum.connect", + severity="warn", + error_class=exc.__class__.__name__, + error_message=str(exc), + ) + return + if classes is None: + return + if not reticulum_interfaces.RNODE_INTERFACE_CLASSES & set(classes.values()): + config._debug_log( + "No RNode interface on this RNS stack; add one or set " + "RETICULUM_INTERFACES=*", + context="reticulum.connect", + severity="warn", + # Classes, not names: a spawned interface's name can carry a + # peer's address (a TCP server's clients, AutoInterface peers). + interface_classes=sorted(set(classes.values())), + ) + + def _announce_admitted(hops: int | None, interface_name: str | None) -> bool: """Decide whether an announce is in scope for this ingestor (SPEC RN4). @@ -404,7 +500,10 @@ def _announce_admitted(hops: int | None, interface_name: str | None) -> bool: **Anything further out is scoped by interface.** From one hop the receiving interface is a real one, so :data:`~data.mesh_ingestor.config.RETICULUM_INTERFACES` can discriminate. - An empty allowlist — the default — admits everything. + ``*`` admits every interface. An unknown interface is rejected. The + default, an empty allowlist, admits an RNode interface only + (:func:`_is_rnode_interface`); any other allowlist admits a name that + contains one of its fragments. Parameters: hops: Hop count for the announce, or ``None`` when unknown. @@ -416,10 +515,12 @@ def _announce_admitted(hops: int | None, interface_name: str | None) -> bool: if hops == 0: return True allowlist = config.RETICULUM_INTERFACES - if not allowlist: + if config.RETICULUM_ALL_INTERFACES in allowlist: return True if not interface_name: return False + if not allowlist: + return _is_rnode_interface(interface_name) lowered = interface_name.lower() return any(fragment in lowered for fragment in allowlist) @@ -578,7 +679,7 @@ class _ReticulumAnnounceHandler: aspect=self.aspect_filter, hops=hops, interface=interface_name, - allowlist=list(config.RETICULUM_INTERFACES), + allowlist=_interface_scope(), ) return handlers._mark_packet_seen() @@ -896,13 +997,20 @@ def _is_lora_interface(interface: object) -> bool: LoRa involved, so attributing the operator's radio settings to it would publish (and federate) a claim about that peer that is simply untrue. + The test is the RNode default scope's (:func:`_is_rnode_interface`): by + class where the stack reports it, so a multi-radio RNode's sub-interface + counts although its name carries no "rnode", and by name otherwise. It + tags the record this provider builds; ``handlers.upsert_node`` currently + stamps the configured values on every posted record whatever the + interface (a known gap outside this provider). + Parameters: interface: Interface string an announce arrived on, if known. Returns: ``True`` only for an RNode interface. """ - return isinstance(interface, str) and "rnode" in interface.lower() + return isinstance(interface, str) and _is_rnode_interface(interface) def _attach_radio_metadata(node: dict) -> None: @@ -1285,9 +1393,10 @@ class ReticulumProvider: context="reticulum.connect", severity="info", aspects=list(_ANNOUNCE_ASPECTS), - interfaces=list(config.RETICULUM_INTERFACES) or "all", + interfaces=_interface_scope(), node_id=host_node_id or "pending", ) + _check_rnode_scope() if not host_node_id: # Say so explicitly: a fresh stack has nothing 0-hop in its path # table yet, the daemon retries every loop, and an operator reading @@ -1441,6 +1550,7 @@ __all__ = [ "_identity_from_announce", "_identity_public_key_hex", "_announce_admitted", + "_is_rnode_interface", "_reticulum_hash_hex", "_reticulum_node_id", "_reticulum_short_name", diff --git a/data/mesh_ingestor/protocols/reticulum_interfaces.py b/data/mesh_ingestor/protocols/reticulum_interfaces.py new file mode 100644 index 00000000..9a24d7c8 --- /dev/null +++ b/data/mesh_ingestor/protocols/reticulum_interfaces.py @@ -0,0 +1,184 @@ +# Copyright © 2025-26 l5yth & contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Interface classes of the running Reticulum stack (SPEC RN4, amended). + +The default interface scope admits an announce only when it arrived on an +RNode, and the interface's **class** says so where its name cannot: an +``RNodeSubInterface`` of an ``RNodeMultiInterface`` prints as +``[]`` with no type text at all. + +``get_interface_stats()`` lists every interface the stack runs, spawned ones +included, each with its printed name (``str(interface)``, the same string +``get_next_hop_if_name`` returns) and its class name. A shared-instance client +RPCs it to ``rnsd``, the route SPEC RE3 uses for names, so the map describes +the stack that received the announce rather than this process. +""" + +from __future__ import annotations + +import threading +import time +from collections.abc import Callable + +RNODE_INTERFACE_CLASSES: frozenset[str] = frozenset( + {"RNodeInterface", "RNodeMultiInterface", "RNodeSubInterface"} +) +"""RNS interface classes that drive an RNode radio.""" + +REFRESH_SECONDS = 30.0 +"""Minimum seconds between two reads that a lookup miss triggers.""" + + +def interface_classes(stats: object) -> dict[str, str] | None: + """Map each interface's printed name to its class name. + + Parameters: + stats: A ``get_interface_stats()`` reply, whose ``interfaces`` entries + carry ``name`` (the printed name) and ``type`` (the class name). + + Returns: + ``{printed name: class name}`` without the malformed entries, or + ``None`` when *stats* holds no interface list. + """ + if not isinstance(stats, dict): + return None + entries = stats.get("interfaces") + if not isinstance(entries, (list, tuple)): + return None + classes: dict[str, str] = {} + for entry in entries: + if not isinstance(entry, dict): + continue + name, kind = entry.get("name"), entry.get("type") + if isinstance(name, str) and isinstance(kind, str): + classes[name] = kind + return classes + + +class InterfaceClassCache: + """Cached name-to-class map of the running stack's interfaces. + + :meth:`read` re-reads at once (the connect path). :meth:`class_of` + re-reads on a miss, at most once per refresh window, so announces from an + interface spawned after the last read cost one RPC per window rather than + one each. A failed read keeps the previous map and still starts the + window. The map belongs to the instance it was read from: a different + instance discards it. Thread-safe, because RNS calls announce handlers on + threads of its own. + """ + + def __init__( + self, + get_instance: Callable[[], object], + *, + clock: Callable[[], float] = time.monotonic, + refresh_seconds: float = REFRESH_SECONDS, + ) -> None: + """Bind the cache to the running stack. + + Parameters: + get_instance: Returns the running ``RNS.Reticulum``, or ``None``. + clock: Monotonic seconds, for the refresh window. + refresh_seconds: Minimum seconds between two miss-triggered reads. + """ + self._get_instance = get_instance + self._clock = clock + self._refresh_seconds = refresh_seconds + self._lock = threading.Lock() + self._instance: object | None = None + self._classes: dict[str, str] | None = None + self._read_at: float | None = None + + def read(self) -> dict[str, str] | None: + """Re-read the map now, whatever the refresh window. + + Returns: + The fresh map, or ``None`` when no stack runs, it exposes no + ``get_interface_stats``, or its reply holds no interface list. + + Raises: + Exception: Whatever ``get_interface_stats`` raises, such as the + ``AuthenticationError`` of an RPC the shared instance rejects. + """ + with self._lock: + instance = self._bind() + return None if instance is None else self._refresh(instance) + + def class_of(self, interface_name: str) -> str | None: + """Return the class of the interface printed as *interface_name*. + + Parameters: + interface_name: The interface's printed name, as RE3 resolves it. + + Returns: + Its class name, or ``None`` when the stack cannot say: no stack, + no stats, a failed read, or a name the stack does not list. + """ + with self._lock: + instance = self._bind() + if instance is None: + return None + if self._classes is not None and interface_name in self._classes: + return self._classes[interface_name] + if ( + self._read_at is not None + and self._clock() - self._read_at < self._refresh_seconds + ): + return None + try: + classes = self._refresh(instance) + except Exception: + return None + return None if classes is None else classes.get(interface_name) + + def _bind(self) -> object | None: + """Return the running instance, discarding a map read from another one. + + Returns: + The instance, or ``None`` when there is none or asking fails. + """ + try: + instance = self._get_instance() + except Exception: + return None + if instance is not self._instance: + self._instance, self._classes, self._read_at = instance, None, None + return instance + + def _refresh(self, instance: object) -> dict[str, str] | None: + """Read *instance*'s interface stats into the map. + + Parameters: + instance: The running ``RNS.Reticulum``. + + Returns: + The fresh map, or ``None`` when *instance* has no stats to read. + """ + # Stamped before the read, so a failing read also starts the window. + self._read_at = self._clock() + reader = getattr(instance, "get_interface_stats", None) + if reader is None: + return None + classes = interface_classes(reader()) + if classes is not None: + self._classes = classes + return classes + + +__all__ = [ + "REFRESH_SECONDS", + "RNODE_INTERFACE_CLASSES", + "InterfaceClassCache", + "interface_classes", +] diff --git a/docker-compose.yml b/docker-compose.yml index 12301dfd..3f5e76c9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -71,9 +71,8 @@ x-ingestor-base: &ingestor-base # stack rnsd already uses; a container has no such stack, so it points at # the volume that persists instead. RETICULUM_CONFIG_DIR: ${RETICULUM_CONFIG_DIR:-/app/.config/potato-mesh/reticulum} - # NB: the default must be bare, not `""` — Compose substitutes the default - # as literal text, so `:-""` yields a two-character value that parses as a - # one-entry allowlist matching nothing (i.e. ingests no announces at all). + # Unset ingests RNode interfaces only; set * to ingest every interface. + # Keep the default bare, not `""`: Compose substitutes it as literal text. RETICULUM_INTERFACES: ${RETICULUM_INTERFACES:-} # Read from the RNS config when unset; these only override it. RETICULUM_FREQ: ${RETICULUM_FREQ:-} diff --git a/flake.nix b/flake.nix index 583f1056..35ca1604 100644 --- a/flake.nix +++ b/flake.nix @@ -408,7 +408,7 @@ reticulumInterfaces = lib.mkOption { type = lib.types.nullOr lib.types.str; default = null; - description = "Comma-separated case-insensitive substrings of RNS interface names to ingest from; null ingests from every interface"; + description = "RNS interfaces to ingest from; null ingests RNode interfaces only, * ingests every interface, otherwise comma-separated case-insensitive substrings of interface names"; }; primaryChannelOnly = lib.mkOption { diff --git a/tests/test_ci_runtime_sync.py b/tests/test_ci_runtime_sync.py index 21070954..79965b7e 100644 --- a/tests/test_ci_runtime_sync.py +++ b/tests/test_ci_runtime_sync.py @@ -47,14 +47,16 @@ RUNTIME_PINS = ( ), ) -# Files the guards in this module and in test_dependency_manifests.py read. -# A pull request touching any of them must run the Python workflow. Pushes to -# main run it unfiltered, which still covers what these guards read beyond -# this list (the edit hook, .gitignore, a manifest in a new directory). +# Files the guards in this module, in test_dependency_manifests.py and in +# test_configure_script.py read. A pull request touching any of them must run +# the Python workflow. Pushes to main run it unfiltered, which still covers what +# these guards read beyond this list (the edit hook, .gitignore, a manifest in a +# new directory). GUARDED_INPUTS = ( ".github/dependabot.yml", ".github/workflows/python.yml", ".github/workflows/ruby.yml", + "configure.sh", "data/Dockerfile", "data/requirements-dev.txt", "data/requirements.txt", diff --git a/tests/test_config_unit.py b/tests/test_config_unit.py index 1d132c5e..930e8a83 100644 --- a/tests/test_config_unit.py +++ b/tests/test_config_unit.py @@ -427,11 +427,19 @@ class TestReticulumConfigDir: class TestReticulumInterfaces: """Tests for the RETICULUM_INTERFACES ingest allowlist (#888).""" - def test_empty_by_default(self): - """An unset or blank allowlist admits every interface.""" + def test_unset_is_the_rnode_default(self): + """Unset or blank parses to ``()``: the RNode scope (SPEC RN4, amended). + + The provider reads ``()`` as "RNode interfaces only"; every interface + takes an explicit ``*`` (see :meth:`test_star_means_every_interface`). + An unset variable reaches the parser as ``""``. + """ assert config._parse_reticulum_interfaces("") == () assert config._parse_reticulum_interfaces(" ") == () assert config._parse_reticulum_interfaces(",, ,") == () + assert config._parse_reticulum_interfaces("") != ( + config._parse_reticulum_interfaces("*") + ) def test_splits_lowercases_dedupes_and_sorts(self): """Entries are normalised so matching is case-insensitive and stable.""" @@ -440,8 +448,8 @@ class TestReticulumInterfaces: "serial", ) - def test_quote_only_value_resolves_to_no_allowlist(self): - """A literal '\"\"' must mean *no* allowlist, not one matching nothing. + def test_quote_only_value_is_the_rnode_default(self): + """A literal '\"\"' is the RNode default, not an allowlist matching nothing. Compose substitutes a ``${VAR:-""}`` default as literal text, so the ingestor can receive the two-character string ``""``. Read naively @@ -452,6 +460,17 @@ class TestReticulumInterfaces: assert config._parse_reticulum_interfaces("''") == () assert config._parse_reticulum_interfaces('" "') == () + def test_star_means_every_interface(self): + """``*`` anywhere in the list is every interface, alone; ``all`` is a name. + + The provider admits every interface on ``("*",)`` (SPEC RN4, amended), + so the rest of a list naming ``*`` is dropped rather than shown. + """ + for raw in ("*", " * ", "rnode, *", "*,serial,RNode"): + assert config._parse_reticulum_interfaces(raw) == ("*",), raw + assert config._parse_reticulum_interfaces("all") == ("all",) + assert config.RETICULUM_ALL_INTERFACES == "*" + def test_fragments_with_content_are_taken_literally(self): """Only quote-*only* fragments are dropped; content is never rewritten. diff --git a/tests/test_configure_script.py b/tests/test_configure_script.py new file mode 100644 index 00000000..a320828d --- /dev/null +++ b/tests/test_configure_script.py @@ -0,0 +1,213 @@ +# Copyright © 2025-26 l5yth & contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Unit tests for ``configure.sh``, the Docker Compose setup script. + +Each test runs the real script under ``bash`` in pytest's ``tmp_path`` with its +answers piped on stdin, then asserts on the Compose env file it writes there +and on the lines it echoes. ``read -p`` prints no prompt without a terminal, +so the answers are one line per prompt, in the order the script asks. + +Under ``PROTOCOL=reticulum`` the script asks for ``INGESTOR_NODE_ID``, which +Docker needs (SPEC RE8), and states the RNode default of +``RETICULUM_INTERFACES`` (SPEC RN4, amended). +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] + +SCRIPT = REPO_ROOT / "configure.sh" +"""The script under test.""" + +ENV_FILE = ".env" +"""The Compose env file the script writes in its working directory.""" + +TOKEN = "f" * 64 +"""A seeded API token, so the script keeps it rather than generating one.""" + +NODE_ID = "!27716218" +"""A canonical ``!xxxxxxxx`` node id.""" + +IDENTITY_HASH = "27716218762CFD2864141EF286C39940" +"""A full 32-hex identity hash, upper-cased to cover the ``A-F`` range.""" + +RETRY = "Not a valid node id" +"""Start of the line the script prints before asking for the node id again.""" + +_SITE = [""] * 5 +"""Instance domain, site name, map centre, map zoom, max distance: defaults.""" + +_SHARED = [""] * 8 +"""Chat link, debug, federation, private mode, allowed and hidden channels, +image architecture and image tag: defaults.""" + + +def _reticulum(*node_id_answers: str, interfaces: str = "") -> list[str]: + """Return the answers for a ``reticulum`` run. + + Parameters: + node_id_answers: Each answer to the node id prompt, retries included. + interfaces: Answer to the interfaces prompt. + + Returns: + One answer per prompt: the RNS config dir, the node id attempts, the + interfaces, frequency, preset, transmit and keep-token prompts follow + the protocol and shared sections. + """ + return [ + *_SITE, + "reticulum", + *_SHARED, + "", + *node_id_answers, + interfaces, + "", + "", + "", + "", + ] + + +def _meshtastic() -> list[str]: + """Return the answers for a ``meshtastic`` run: every prompt at its default. + + Returns: + Protocol, preset, frequency, the shared section, connection target, + transmit and keep-token answers. + """ + return [*_SITE, "meshtastic", "", "", *_SHARED, "", "", ""] + + +def _run( + tmp_path: Path, answers: list[str], **seed: str +) -> subprocess.CompletedProcess: + """Seed the env file and run ``configure.sh`` in *tmp_path*. + + Parameters: + tmp_path: Working directory for the run. + answers: One line per prompt. + seed: ``KEY=VALUE`` pairs written (quoted) before the run. + + Returns: + The finished process, with text stdout and stderr. + """ + lines = [f"API_TOKEN={TOKEN}", *(f'{key}="{value}"' for key, value in seed.items())] + (tmp_path / ENV_FILE).write_text("\n".join(lines) + "\n", encoding="utf-8") + return subprocess.run( + ["bash", str(SCRIPT)], + cwd=tmp_path, + input="\n".join(answers) + "\n", + capture_output=True, + text=True, + timeout=60, + check=False, + ) + + +def _configure(tmp_path: Path, answers: list[str], **seed: str) -> tuple[str, dict]: + """Run ``configure.sh`` to completion and read the env file it wrote. + + Parameters: + tmp_path: Working directory for the run. + answers: One line per prompt. + seed: ``KEY=VALUE`` pairs written (quoted) before the run. + + Returns: + ``(stdout, env)`` where *env* maps each written key to its raw value, + quotes included. + """ + result = _run(tmp_path, answers, **seed) + assert result.returncode == 0, result.stderr + pairs = ( + line.split("=", 1) + for line in (tmp_path / ENV_FILE).read_text(encoding="utf-8").splitlines() + if "=" in line and not line.startswith("#") + ) + return result.stdout, dict(pairs) + + +def test_reticulum_asks_for_the_ingestor_node_id(tmp_path): + """Docker Reticulum needs the id (SPEC RE8): asked, written, summarised.""" + out, env = _configure(tmp_path, _reticulum(NODE_ID)) + assert (env.get("INGESTOR_NODE_ID"), env.get("RETICULUM_INTERFACES")) == ( + f'"{NODE_ID}"', + None, + ) + assert "Required in Docker.\n" in out + assert f"Ingestor Node ID: {NODE_ID}" in out + + +def test_reticulum_node_id_is_validated(tmp_path): + """Blank, malformed and short answers are asked again; the valid one is kept.""" + attempts = ["", "nonsense", "27716218", "!2771621", NODE_ID] + out, env = _configure(tmp_path, _reticulum(*attempts)) + assert env.get("INGESTOR_NODE_ID") == f'"{NODE_ID}"' + assert out.count(RETRY) == len(attempts) - 1 + assert "RETICULUM_INTERFACES" not in env + + +def test_reticulum_accepts_a_32_hex_identity_hash(tmp_path): + """The full identity hash is written as typed; the provider maps it (RE5).""" + out, env = _configure(tmp_path, _reticulum(IDENTITY_HASH)) + assert env.get("INGESTOR_NODE_ID") == f'"{IDENTITY_HASH}"' + assert RETRY not in out + + +def test_end_of_input_stops_the_node_id_prompt(tmp_path): + """Input that ends on an invalid id exits with an error instead of looping.""" + # Every answer up to the node id prompt, one invalid id, then end of input. + result = _run(tmp_path, [*_SITE, "reticulum", *_SHARED, "", "nonsense"]) + assert result.returncode != 0 + assert result.stdout.count(RETRY) == 1 + env_text = (tmp_path / ENV_FILE).read_text(encoding="utf-8") + assert "INGESTOR_NODE_ID" not in env_text + + +def test_existing_node_id_is_offered_as_the_default(tmp_path): + """A valid id already in the file is kept by a blank answer.""" + _out, env = _configure(tmp_path, _reticulum(""), INGESTOR_NODE_ID=NODE_ID) + assert env.get("INGESTOR_NODE_ID") == f'"{NODE_ID}"' + + +def test_an_invalid_existing_node_id_is_not_offered(tmp_path): + """A malformed id in the file is no default: a blank answer asks again.""" + out, env = _configure(tmp_path, _reticulum("", NODE_ID), INGESTOR_NODE_ID="!xyz") + assert env.get("INGESTOR_NODE_ID") == f'"{NODE_ID}"' + assert out.count(RETRY) == 1 + + +def test_other_protocols_are_not_asked_and_keep_the_value(tmp_path): + """The UDP transport reads the id too, so a Meshtastic run leaves it alone.""" + out, env = _configure(tmp_path, _meshtastic(), INGESTOR_NODE_ID="!aabbccdd") + assert env.get("INGESTOR_NODE_ID") == '"!aabbccdd"' + assert "Required in Docker" not in out + + +def test_reticulum_summary_states_the_defaults(tmp_path): + """Blank answers mean the Compose volume and RNode interfaces only.""" + out, env = _configure(tmp_path, _reticulum(NODE_ID)) + assert "RNS Config Dir: 'potatomesh_reticulum volume'" in out + assert "Interfaces: 'RNode only'" in out + assert "RETICULUM_CONFIG_DIR" not in env + + +def test_star_is_written_for_every_interface(tmp_path): + """``*`` reaches the env file and the summary verbatim, never globbed.""" + (tmp_path / "decoy").touch() + out, env = _configure(tmp_path, _reticulum(NODE_ID, interfaces="*")) + assert env.get("RETICULUM_INTERFACES") == '"*"' + assert "Interfaces: *" in out diff --git a/tests/test_reticulum_scope_unit.py b/tests/test_reticulum_scope_unit.py new file mode 100644 index 00000000..80022791 --- /dev/null +++ b/tests/test_reticulum_scope_unit.py @@ -0,0 +1,712 @@ +# Copyright © 2025-26 l5yth & contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Unit tests for the Reticulum interface scope (SPEC RN4 as amended, RE3, RE4). + +Unset, ``RETICULUM_INTERFACES`` ingests announces that arrived on an RNode only; +``*`` ingests every interface; any other value is a substring allowlist. + +The real :class:`ReticulumProvider` and its real announce handlers run against +a fake shared-instance RNS stack. Identities and destination hashes come from +the real RNS library, and every interface is a real RNS interface class created +without ``__init__`` (no serial port, no socket), so each one's ``str()`` is +exactly what ``rnsd`` returns over RPC and lists in ``get_interface_stats()``. +""" + +from __future__ import annotations + +import multiprocessing +import re +import sys +import types +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +import RNS as REAL_RNS # noqa: E402 - path setup +from RNS.Interfaces.AutoInterface import ( # noqa: E402 - path setup + AutoInterface, + AutoInterfacePeer, +) +from RNS.Interfaces.BackboneInterface import ( # noqa: E402 - path setup + BackboneClientInterface, +) +from RNS.Interfaces.LocalInterface import LocalClientInterface # noqa: E402 +from RNS.Interfaces.RNodeInterface import RNodeInterface # noqa: E402 +from RNS.Interfaces.RNodeMultiInterface import ( # noqa: E402 - path setup + RNodeMultiInterface, + RNodeSubInterface, +) +from RNS.Interfaces.TCPInterface import TCPClientInterface # noqa: E402 + +import data.mesh_ingestor.config as config # noqa: E402 - path setup +import data.mesh_ingestor.protocols.reticulum as mod # noqa: E402 - path setup + + +def _bare(cls, **attrs): + """Return a real RNS interface built without ``__init__``: no hardware, no I/O. + + Parameters: + cls: RNS interface class. + attrs: Attributes its ``__str__`` reads. + + Returns: + The interface instance. + """ + obj = object.__new__(cls) + for key, value in attrs.items(): + setattr(obj, key, value) + return obj + + +_MULTI = _bare(RNodeMultiInterface, name="Multi Radio") +_AUTO = _bare(AutoInterface, name="Default Interface") + +IFACES = { + "rnode": _bare(RNodeInterface, name="RNode LoRa Interface"), + "tcp": _bare( + TCPClientInterface, + name="RNS Testnet Amsterdam", + target_ip="amsterdam.connect.reticulum.network", + target_port=4965, + ), + "backbone": _bare( + BackboneClientInterface, + name="Backbone Peer", + target_ip="203.0.113.7", + target_port=4242, + ), + "multi": _MULTI, + "rnode_sub": _bare(RNodeSubInterface, name="868 Low", parent_interface=_MULTI), + "auto": _AUTO, + "auto_peer": _bare( + AutoInterfacePeer, ifname="eth0", addr="fe80::1", parent_interface=_AUTO + ), + "local": _bare(LocalClientInterface, socket_path="\0rns/default"), +} +"""Real RNS interfaces, keyed by role. ``local`` is this client's own socket.""" + +PEERS = ( + ("tcp_peer", "tcp", 3, "Amsterdam Hub User"), + ("backbone_peer", "backbone", 2, "Backbone Peer User"), + ("rnode_peer", "rnode", 1, "LoRa Neighbour"), + ("rnode_sub_peer", "rnode_sub", 1, "Multi Radio Neighbour"), + ("auto_peer", "auto_peer", 1, "LAN Peer"), + ("own", "local", 0, "Operator Node"), +) +"""(label, arrival interface, hops, display name) per peer; ``own`` is this host.""" + +ALL = {label for label, *_ in PEERS} +"""Every peer label.""" + +FULL_STACK = ("rnode", "tcp", "backbone", "multi", "rnode_sub", "auto", "auto_peer") +"""The interfaces rnsd runs on the default fake stack.""" + +NO_RNODE_STACK = ("tcp", "backbone", "auto", "auto_peer") +"""A stack with IP interfaces only.""" + +_TCP = str(IFACES["tcp"]) +"""The TCP interface's printed name, as rnsd reports it.""" + + +def _stats_entry(iface) -> dict: + """Return the ``get_interface_stats()`` entry RNS builds for *iface*. + + Only the keys the scope can read: ``name`` is ``str(interface)``, ``type`` + the class name, and a spawned interface also names its parent + (``Reticulum.get_interface_stats``). + + Parameters: + iface: A real RNS interface. + + Returns: + The stats entry. + """ + entry = { + "name": str(iface), + "short_name": str(getattr(iface, "name", None)), + "type": type(iface).__name__, + } + parent = getattr(iface, "parent_interface", None) + if parent is not None: + entry["parent_interface_name"] = str(parent) + return entry + + +class _Stack: + """rnsd's state behind the fake RNS namespace. + + Holds the interfaces rnsd runs, the path to each peer's ``lxmf.delivery`` + destination, and who owns it. The operator also runs a nomadnet node, so + host discovery finds two local destinations of one identity (SPEC RE8). + """ + + def __init__(self, interfaces=FULL_STACK, *, stats="ok"): + """Build the stack. + + Parameters: + interfaces: Keys of :data:`IFACES` rnsd runs. + stats: ``"ok"``, ``"missing"`` (the instance has no + ``get_interface_stats``), or an exception every RPC raises. + """ + self.interfaces = [IFACES[key] for key in interfaces] + self.stats = stats + self.stats_calls = 0 + self.registered: list = [] + self.paths: dict[bytes, tuple[int, object]] = {} + self.owners: dict[bytes, object] = {} + self.dests: dict[str, bytes] = {} + self.transport_identity = REAL_RNS.Identity() + self.identities = {label: REAL_RNS.Identity() for label, *_ in PEERS} + for label, key, hops, _name in PEERS: + dest = REAL_RNS.Destination.hash(self.identities[label], "lxmf", "delivery") + self.dests[label] = dest + self.paths[dest] = (hops, IFACES[key]) + self.owners[dest] = self.identities[label] + own = self.identities["own"] + nomad = REAL_RNS.Destination.hash(own, "nomadnetwork", "node") + self.paths[nomad] = (0, IFACES["local"]) + self.owners[nomad] = own + + def _rpc(self): + """Raise the configured RPC failure, if any.""" + if isinstance(self.stats, BaseException): + raise self.stats + + def next_hop_if_name(self, dest) -> str: + """Answer ``get_next_hop_if_name``: ``str()`` of rnsd's next-hop interface.""" + self._rpc() + entry = self.paths.get(bytes(dest)) + return str(entry[1]) if entry else "None" + + def path_table(self, max_hops=None) -> list[dict]: + """Answer ``get_path_table`` for paths within *max_hops*.""" + self._rpc() + return [ + {"hash": dest, "hops": hops, "interface": str(iface)} + for dest, (hops, iface) in self.paths.items() + if max_hops is None or hops <= max_hops + ] + + def interface_stats(self) -> dict: + """Answer ``get_interface_stats``, counting the calls.""" + self.stats_calls += 1 + self._rpc() + return { + "interfaces": [_stats_entry(iface) for iface in self.interfaces], + "transport_id": None, + } + + +def _fake_rns(stack: _Stack): + """Return a fake ``RNS`` namespace: a client attached to a shared ``rnsd``. + + Parameters: + stack: The rnsd state the shared instance answers from. + + Returns: + The namespace the provider reads as ``RNS``. + """ + shared = types.SimpleNamespace( + is_connected_to_shared_instance=True, + get_next_hop_if_name=stack.next_hop_if_name, + get_path_table=stack.path_table, + ) + if stack.stats != "missing": + shared.get_interface_stats = stack.interface_stats + transport = types.SimpleNamespace( + PATHFINDER_M=128, + register_announce_handler=stack.registered.append, + deregister_announce_handler=lambda _handler: None, + hops_to=lambda dest: stack.paths.get(bytes(dest), (128, None))[0], + # This client's own path table: everything reads as the local socket. + next_hop_interface=lambda _dest: IFACES["local"], + internal_identity=lambda: stack.transport_identity, + ) + return types.SimpleNamespace( + Reticulum=types.SimpleNamespace( + get_instance=lambda: shared, transport_enabled=lambda: False + ), + Transport=transport, + Identity=types.SimpleNamespace( + recall=lambda dest, **_k: stack.owners.get(bytes(dest)), + recall_app_data=lambda _dest: None, + ), + Destination=types.SimpleNamespace(hash=REAL_RNS.Destination.hash), + ) + + +class _Run: + """The real provider on a fake stack, with what it logged, counted and posted.""" + + def __init__(self, monkeypatch, tmp_path, scope, **stack_options): + """Install the fake stack and the scope; nothing is connected yet. + + Parameters: + monkeypatch: pytest fixture. + tmp_path: Directory used as ``RETICULUM_CONFIG_DIR`` (no config). + scope: Value for ``config.RETICULUM_INTERFACES``. + stack_options: Keyword arguments for :class:`_Stack`. + """ + self.stack = _Stack(**stack_options) + self.logs: list = [] + self.upserts: list = [] + self.seen = 0 + self.iface = None + monkeypatch.setattr(mod, "RNS", _fake_rns(self.stack)) + monkeypatch.setattr(config, "RETICULUM_INTERFACES", scope) + monkeypatch.setattr(config, "RETICULUM_CONFIG_DIR", str(tmp_path)) + for name in ("RETICULUM_FREQ", "RETICULUM_PRESET", "INGESTOR_NODE_ID"): + monkeypatch.setattr(config, name, None) + monkeypatch.setattr(config, "CONNECTION", None) + monkeypatch.setattr(config, "LORA_FREQ", 867) + monkeypatch.setattr(config, "MODEM_PRESET", "SF8/BW125/CR5") + monkeypatch.setattr( + config, "_debug_log", lambda msg, **kw: self.logs.append((msg, kw)) + ) + monkeypatch.setattr( + mod.handlers, + "upsert_node", + lambda nid, node: self.upserts.append((nid, node)), + ) + monkeypatch.setattr(mod.handlers, "_mark_packet_seen", self._count) + + def _count(self) -> None: + """Stand in for ``handlers._mark_packet_seen``.""" + self.seen += 1 + + def connect(self) -> "_Run": + """Connect the real provider; returns ``self`` for chaining.""" + self.iface, _target, _next = mod.ReticulumProvider().connect( + active_candidate=None + ) + return self + + def announce(self, *labels: str) -> None: + """Dispatch each peer's ``lxmf.delivery`` announce (default: every peer).""" + handler = next( + h for h in self.stack.registered if h.aspect_filter == "lxmf.delivery" + ) + names = {label: name for label, _key, _hops, name in PEERS} + for label in labels or [label for label, *_ in PEERS]: + handler.received_announce( + destination_hash=self.stack.dests[label], + announced_identity=self.stack.identities[label], + app_data=names[label].encode("utf-8"), + ) + + def node_id(self, label: str) -> str: + """Return the canonical node id of the peer *label*.""" + return mod._reticulum_node_id(self.stack.identities[label].hash) + + @property + def ingested(self) -> set[str]: + """Labels of the peers that were posted.""" + label_of = {self.node_id(label): label for label in ALL} + return {label_of[node_id] for node_id, _node in self.upserts} + + def record(self, label: str) -> dict: + """Return the node record last posted for *label*.""" + return [node for nid, node in self.upserts if nid == self.node_id(label)][-1] + + def warnings(self) -> list[str]: + """Return every message logged at ``warn``.""" + return [msg for msg, kw in self.logs if kw.get("severity") == "warn"] + + def startup(self) -> dict: + """Return the fields of the first "listener registered" line.""" + return next(kw for msg, kw in self.logs if "listener registered" in msg) + + +# --------------------------------------------------------------------------- +# The RNode default (SPEC RN4 as amended) +# --------------------------------------------------------------------------- + + +def test_unset_scope_drops_a_tcp_announce(monkeypatch, tmp_path): + """Unset, a TCP announce from 3 hops out is neither posted nor counted.""" + run = _Run(monkeypatch, tmp_path, ()).connect() + assert mod._announce_admitted(3, _TCP) is False + run.announce("tcp_peer") + assert run.upserts == [] + assert run.seen == 0 + + +def test_unset_scope_drops_backbone_and_autointerface_announces(monkeypatch, tmp_path): + """A Backbone peer and an AutoInterface peer are IP, not RNode: dropped.""" + run = _Run(monkeypatch, tmp_path, ()).connect() + run.announce("backbone_peer", "auto_peer") + assert run.ingested == set() + assert run.seen == 0 + + +def test_unset_scope_admits_an_rnode_announce(monkeypatch, tmp_path): + """An announce heard on an RNodeInterface is ingested and counted.""" + run = _Run(monkeypatch, tmp_path, ()).connect() + run.announce("rnode_peer") + assert run.ingested == {"rnode_peer"} + assert run.seen == 1 + assert run.record("rnode_peer")["interface"] == str(IFACES["rnode"]) + + +def test_unset_scope_admits_an_rnode_multi_subinterface(monkeypatch, tmp_path): + """A multi-radio RNode's sub-interface is an RNode, though its name says not. + + It prints as ``[]`` with no type text, so the class that + ``get_interface_stats()`` reports for that name decides. + """ + run = _Run(monkeypatch, tmp_path, ()).connect() + run.announce("rnode_sub_peer") + assert run.ingested == {"rnode_sub_peer"} + assert run.record("rnode_sub_peer")["interface"] == "Multi Radio[868 Low]" + + +def test_unset_scope_keeps_this_machines_announces(monkeypatch, tmp_path): + """0-hop announces and the host's own destinations stay in (SPEC RE4).""" + run = _Run(monkeypatch, tmp_path, ()).connect() + run.announce("own") + assert run.ingested == {"own"} + host = run.node_id("own") + assert run.startup()["node_id"] == host + snapshot = mod.ReticulumProvider().node_snapshot_items(run.iface) + assert host in {node_id for node_id, _node in snapshot} + + +@pytest.mark.parametrize("raw", ["", '""', "''", " "]) +def test_compose_empty_value_is_the_rnode_scope(monkeypatch, tmp_path, raw): + """What Compose, the image and a null NixOS option deliver is the default. + + ``${RETICULUM_INTERFACES:-}`` and both ``data/Dockerfile`` stages deliver an + empty string; a quoted default would deliver ``""``. + """ + run = _Run(monkeypatch, tmp_path, config._parse_reticulum_interfaces(raw)) + run.connect() + assert mod._announce_admitted(3, _TCP) is False + run.announce() + assert run.ingested == {"rnode_peer", "rnode_sub_peer", "own"} + + +def test_star_ingests_every_interface(monkeypatch, tmp_path): + """``*`` is the old default: every interface, IP included.""" + run = _Run(monkeypatch, tmp_path, config._parse_reticulum_interfaces("*")) + run.connect().announce() + assert run.ingested == ALL + assert run.seen == len(PEERS) + + +def test_explicit_list_keeps_the_substring_rule(monkeypatch, tmp_path): + """A list still matches names, so ``rnode`` misses a sub-interface.""" + run = _Run(monkeypatch, tmp_path, config._parse_reticulum_interfaces("RNode")) + run.connect().announce() + assert run.ingested == {"rnode_peer", "own"} + assert run.warnings() == [] + + +def test_the_scope_rules_apply_in_order(monkeypatch, tmp_path): + """0 hops, then ``*``, then a missing name, then the default or the list.""" + _Run(monkeypatch, tmp_path, ()) + assert mod._announce_admitted(0, None) is True + assert mod._announce_admitted(1, None) is False + assert mod._announce_admitted(1, "") is False + monkeypatch.setattr(config, "RETICULUM_INTERFACES", ("*",)) + assert mod._announce_admitted(1, None) is True + assert mod._announce_admitted(None, _TCP) is True + + +# --------------------------------------------------------------------------- +# Connect-time warnings and the startup line +# --------------------------------------------------------------------------- + + +def test_connect_warns_once_when_the_stack_has_no_rnode(monkeypatch, tmp_path): + """No RNode on the stack: one warning per connect, and only 0 hops survive.""" + run = _Run(monkeypatch, tmp_path, (), interfaces=NO_RNODE_STACK).connect() + scope_warnings = [w for w in run.warnings() if "RETICULUM_INTERFACES=*" in w] + assert len(scope_warnings) == 1 + assert "No RNode interface" in scope_warnings[0] + # Classes, never names: a spawned interface's name can carry a peer address. + [fields] = [kw for msg, kw in run.logs if "No RNode interface" in msg] + assert fields["interface_classes"] == [ + "AutoInterface", + "AutoInterfacePeer", + "BackboneClientInterface", + "TCPClientInterface", + ] + names = [str(IFACES[key]) for key in NO_RNODE_STACK] + assert not any(name in str(fields) for name in names) + run.announce("tcp_peer", "backbone_peer", "auto_peer", "own") + # Never "fall back to all": the IP peers stay out. + assert run.ingested == {"own"} + assert len(run.warnings()) == 1 + run.connect() + assert len(run.warnings()) == 2 + + +def test_connect_warns_when_the_stats_rpc_is_rejected(monkeypatch, tmp_path): + """A rejected RPC turns every name into LocalInterface[...]: say so. + + A shared instance authenticates RPC with a key from the config dir's + identity, so an ingestor on another config dir is refused (SPEC RE3). + """ + refused = multiprocessing.AuthenticationError("digest received was wrong") + run = _Run(monkeypatch, tmp_path, (), stats=refused).connect() + [(warning, fields)] = [ + (msg, kw) for msg, kw in run.logs if kw.get("severity") == "warn" + ] + assert "RETICULUM_CONFIG_DIR" in warning + assert fields["error_class"] == "AuthenticationError" + run.announce() + assert run.ingested == {"own"} + + +def test_stack_without_stats_falls_back_to_the_rnode_name(monkeypatch, tmp_path): + """No stats to read: the name decides, silently (no warning at connect).""" + run = _Run(monkeypatch, tmp_path, (), stats="missing").connect() + assert run.warnings() == [] + run.announce() + # A single-radio RNode prints "RNodeInterface[...]"; a sub-interface does not. + assert run.ingested == {"rnode_peer", "own"} + + +@pytest.mark.parametrize("raw", ["testnet", "*"]) +def test_an_explicit_scope_never_warns_about_a_missing_rnode( + monkeypatch, tmp_path, raw +): + """A list or ``*`` is the operator's choice; only the default checks.""" + run = _Run( + monkeypatch, + tmp_path, + config._parse_reticulum_interfaces(raw), + interfaces=NO_RNODE_STACK, + ) + assert run.connect().warnings() == [] + + +@pytest.mark.parametrize( + ("raw", "expected"), + [("", "rnode"), ("*", "*"), ("RNode, serial", ["rnode", "serial"])], +) +def test_startup_line_names_the_scope(monkeypatch, tmp_path, raw, expected): + """The startup line says which scope is active, never "all".""" + run = _Run(monkeypatch, tmp_path, config._parse_reticulum_interfaces(raw)) + assert run.connect().startup()["interfaces"] == expected + + +# --------------------------------------------------------------------------- +# The provider's radio-metadata tag follows the same class check (SPEC RL1) +# --------------------------------------------------------------------------- + + +def test_the_provider_tags_both_rnode_peers_with_radio_metadata(monkeypatch, tmp_path): + """The provider tags both RNode peers' records with the LoRa values. + + ``run.record`` is the record the provider hands to ``handlers.upsert_node``, + which ``_Run`` stubs. The real one stamps the configured values on every + posted record whatever the interface, a known gap outside this provider. + """ + run = _Run(monkeypatch, tmp_path, ("*",)).connect() + run.announce() + stamped = {label for label in run.ingested if "lora_freq" in run.record(label)} + assert stamped == {"rnode_peer", "rnode_sub_peer"} + + +# --------------------------------------------------------------------------- +# The name-to-class map (reticulum_interfaces) +# --------------------------------------------------------------------------- + + +class _Instance: + """A stack whose interface list can change, or fail, between reads.""" + + def __init__(self, *keys: str): + """List the :data:`IFACES` named by *keys*.""" + self.entries = [_stats_entry(IFACES[key]) for key in keys] + self.calls = 0 + self.error: BaseException | None = None + + def get_interface_stats(self) -> dict: + """Return the stats, or raise :attr:`error`.""" + self.calls += 1 + if self.error is not None: + raise self.error + return {"interfaces": list(self.entries)} + + +@pytest.fixture +def classes(): + """Return :mod:`data.mesh_ingestor.protocols.reticulum_interfaces`. + + Reached through the provider module that imports it, so this file still + imports, and its regression tests still run, on a tree without it. + """ + return mod.reticulum_interfaces + + +def _cache(classes, serving, now=(0.0,)): + """Return an ``InterfaceClassCache`` reading ``serving[0]`` as the instance. + + Parameters: + classes: The :func:`classes` fixture. + serving: One-item list holding the instance, so a test can swap it. + now: One-item sequence holding the clock reading. + """ + return classes.InterfaceClassCache(lambda: serving[0], clock=lambda: now[0]) + + +def test_the_provider_reads_the_running_stack(monkeypatch, tmp_path): + """The provider's map asks ``RNS.Reticulum.get_instance()`` (shared, RE3).""" + run = _Run(monkeypatch, tmp_path, ()) + assert mod._running_instance() is mod.RNS.Reticulum.get_instance() + assert mod._INTERFACE_CLASSES.class_of(_TCP) == "TCPClientInterface" + assert run.stack.stats_calls == 1 + + +def test_class_map_is_cached_and_reread_on_a_miss(classes): + """A hit costs nothing; a miss re-reads, at most once per refresh window.""" + now = [100.0] + stack = _Instance("rnode") + cache = _cache(classes, [stack], now) + rnode, sub = str(IFACES["rnode"]), str(IFACES["rnode_sub"]) + + assert cache.class_of(rnode) == "RNodeInterface" + assert cache.class_of(rnode) == "RNodeInterface" + assert stack.calls == 1 + # rnsd spawns a sub-interface after the read: inside the window it is unknown. + stack.entries.append(_stats_entry(IFACES["rnode_sub"])) + now[0] += classes.REFRESH_SECONDS - 1 + assert cache.class_of(sub) is None + assert stack.calls == 1 + now[0] += 1 + assert cache.class_of(sub) == "RNodeSubInterface" + assert stack.calls == 2 + + +def test_read_refreshes_now_and_reports_the_failure(classes): + """``read`` ignores the window; an RPC error reaches the caller.""" + stack = _Instance("tcp") + cache = _cache(classes, [stack]) + assert cache.read() == {_TCP: "TCPClientInterface"} + assert cache.read() == {_TCP: "TCPClientInterface"} + assert stack.calls == 2 + stack.error = multiprocessing.AuthenticationError("digest received was wrong") + with pytest.raises(multiprocessing.AuthenticationError): + cache.read() + # The last good map still answers. + assert cache.class_of(_TCP) == "TCPClientInterface" + + +def test_a_malformed_reply_keeps_the_previous_map(classes): + """A reply without an interface list adds nothing and loses nothing.""" + stack = _Instance("tcp") + cache = _cache(classes, [stack]) + assert cache.read() == {_TCP: "TCPClientInterface"} + stack.get_interface_stats = lambda: {"transport_id": None} + assert cache.read() is None + assert cache.class_of(_TCP) == "TCPClientInterface" + + +def test_class_map_lookup_survives_a_dead_or_silent_stack(classes): + """No instance, a raising accessor, no stats method, or a failing RPC: None.""" + serving: list = [None] + cache = _cache(classes, serving) + assert cache.class_of(_TCP) is None + assert cache.read() is None + + def _gone(): + """Stand in for an accessor of a stack that has shut down.""" + raise RuntimeError("stack is gone") + + assert classes.InterfaceClassCache(_gone).class_of(_TCP) is None + + serving[0] = types.SimpleNamespace() + assert cache.class_of(_TCP) is None + assert cache.read() is None + + stack = _Instance("tcp") + stack.error = OSError("rpc down") + serving[0] = stack + assert cache.class_of(_TCP) is None + # The failed read starts the window too: no retry storm per announce. + assert cache.class_of(_TCP) is None + assert stack.calls == 1 + + +def test_a_new_instance_discards_the_map(classes): + """The map belongs to the stack it was read from.""" + serving = [_Instance("rnode")] + cache = _cache(classes, serving) + assert cache.class_of(str(IFACES["rnode"])) == "RNodeInterface" + serving[0] = _Instance("tcp") + assert cache.class_of(str(IFACES["rnode"])) is None + assert cache.class_of(_TCP) == "TCPClientInterface" + + +def test_interface_classes_skips_malformed_stats(classes): + """Anything but a list of name/type entries is skipped, never fatal.""" + parse = classes.interface_classes + assert parse(None) is None + assert parse({"transport_id": None}) is None + assert parse({"interfaces": "nope"}) is None + entries = [None, {"name": 1, "type": "X"}, {"name": "A[b]"}, _stats_entry(_MULTI)] + assert parse({"interfaces": entries}) == { + "RNodeMultiInterface[Multi Radio]": "RNodeMultiInterface" + } + + +def test_every_rnode_class_counts_as_an_rnode(classes): + """The three RNode classes RNS ships, and nothing else.""" + assert classes.RNODE_INTERFACE_CLASSES == { + "RNodeInterface", + "RNodeMultiInterface", + "RNodeSubInterface", + } + rnodes = {type(IFACES[key]).__name__ for key in ("rnode", "multi", "rnode_sub")} + assert rnodes == classes.RNODE_INTERFACE_CLASSES + + +# --------------------------------------------------------------------------- +# Operator docs (ACCEPTANCE RE-A16) +# --------------------------------------------------------------------------- + +_OLD_DEFAULT = re.compile( + r"empty ingests|ingests everything|from every interface|" + r"\(the default\) ingests|admits everything" +) +"""Phrases that stated the old "empty ingests every interface" default.""" + + +@pytest.mark.parametrize( + "name", + [ + "README.md", + "flake.nix", + "configure.sh", + "data/mesh_ingestor/CONTRACTS.md", + "data/mesh_ingestor/config.py", + "data/mesh_ingestor/protocols/reticulum.py", + ], +) +def test_docs_state_the_rnode_default(name): + """No surface still says an unset scope ingests every interface.""" + text = (REPO_ROOT / name).read_text(encoding="utf-8") + assert _OLD_DEFAULT.findall(text) == [], f"{name} still states the old default" + + +def test_readme_names_the_star_scope(): + """The README tells the operator how to get every interface back.""" + readme = (REPO_ROOT / "README.md").read_text(encoding="utf-8") + assert "RETICULUM_INTERFACES=*" in readme diff --git a/tests/test_reticulum_unit.py b/tests/test_reticulum_unit.py index e25880a1..d5b9773b 100644 --- a/tests/test_reticulum_unit.py +++ b/tests/test_reticulum_unit.py @@ -172,7 +172,7 @@ def _fake_rns( def _no_ingestor_node_id(monkeypatch, tmp_path): """Point the provider at *tmp_path* with no operator-supplied node id. - A config dir the test owns, an unscoped allowlist, and + A config dir the test owns, the default (RNode) interface scope, and ``INGESTOR_NODE_ID`` cleared so the derived id is what gets exercised. """ monkeypatch.setattr(_mod.config, "RETICULUM_CONFIG_DIR", str(tmp_path)) @@ -1143,7 +1143,10 @@ def test_received_announce_swallows_handler_errors(monkeypatch): monkeypatch.setattr(_mod.config, "_debug_log", lambda *_a, **_k: None) monkeypatch.setattr(_mod.handlers, "_mark_packet_seen", lambda: None) - def _boom(*_a, **_k): + calls: list = [] + + def _boom(*args, **_k): + calls.append(args) raise RuntimeError("queue down") monkeypatch.setattr(_mod.handlers, "upsert_node", _boom) @@ -1154,6 +1157,9 @@ def test_received_announce_swallows_handler_errors(monkeypatch): handler.received_announce( destination_hash=_DEST_HASH, announced_identity=_FakeIdentity(), app_data=b"X" ) + # Reached exactly once: an announce that is never admitted would pass the + # "must not raise" check above without testing the error path at all. + assert len(calls) == 1 def test_received_announce_latest_announce_wins_in_snapshot(monkeypatch): @@ -1494,11 +1500,17 @@ def test_interface_allowlist_filters_announces(monkeypatch): assert _mod._announce_admitted(1, "RNodeInterface[RNode LoRa]") is True -def test_interface_allowlist_empty_ingests_all(monkeypatch): - """RT-A5: an empty allowlist (the default) ingests every interface.""" +def test_interface_allowlist_empty_is_the_rnode_scope(monkeypatch): + """RT-A5, amended (SPEC RN4): an empty allowlist admits RNode interfaces only. + + No stack runs here, so the name decides: a single-radio RNode prints as + ``RNodeInterface[...]``. The class rule itself is covered in + ``tests/test_reticulum_scope_unit.py``. + """ monkeypatch.setattr(config, "RETICULUM_INTERFACES", (), raising=False) - assert _mod._announce_admitted(1, "AutoInterface[Default Interface]") is True - assert _mod._announce_admitted(1, None) is True + assert _mod._announce_admitted(1, "AutoInterface[Default Interface]") is False + assert _mod._announce_admitted(1, None) is False + assert _mod._announce_admitted(1, "RNodeInterface[RNode LoRa]") is True # --------------------------------------------------------------------------- @@ -1530,7 +1542,11 @@ class TestReticulumDeploymentSurface: ), f"docker-compose.yml does not pass {name} through to the ingestor" def test_quote_only_allowlist_cannot_blackout_ingestion(self): - """Defence in depth for the above: a quoted empty value means no allowlist.""" + """Defence in depth for the above: a quoted empty value is the default. + + It parses like unset, to the RNode scope (SPEC RN4), never to a + one-entry allowlist matching nothing. + """ assert config._parse_reticulum_interfaces('""') == () def test_image_declares_the_reticulum_defaults(self):