Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion backend/routes/analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from core.auth import get_user_nodes
from core.users import ANONYMOUS_USER, AUTH_BYPASS, read_user_from_token
from services import node_bias
from services.node_ref import public_node_ref
from services.public_location import public_node_summary
from services.publication import is_private, private_node_ids

Expand Down Expand Up @@ -82,7 +83,10 @@ async def radar_analytics(request: Request, real_only: bool = False):
# rather than tell the owner anything.
if summary.keys() == {"node_id"}:
continue
nodes[nid] = public_node_summary(nid, summary)
# Built fresh like the per-node route below, so it carries the same
# public handle the cached listing does — or the owner's own node is
# the one node on their map without a name.
nodes[nid] = {**public_node_summary(nid, summary), "node_ref": public_node_ref(nid)}
return Response(
content=orjson.dumps(payload, option=orjson.OPT_SERIALIZE_NUMPY),
media_type="application/json",
Expand All @@ -109,6 +113,10 @@ async def radar_node_analytics(node_id: str, request: Request):
# the same receiver-geometry rewrite has to happen here too, or this route
# is the hole the cached one closed. See services/public_location.py.
summary = public_node_summary(node_id, summary)
# The same public handle the cached listing carries, for the same reason:
# this route is built fresh, so anything the listing adds has to be added
# here too or the two surfaces disagree. See services/node_ref.py.
summary = {**summary, "node_ref": public_node_ref(node_id)}
# Backend-computed bias estimate from claim residuals — same conditional
# shape as the manager's own blocks: present only once the node has
# residual history. The trust block above already blends backend-fed
Expand Down
182 changes: 182 additions & 0 deletions backend/services/node_ref.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
"""The public handle for a node: `node_ref`, never the raw `node_id`.

A `node_id` comes off the board and is what Mender, the TCP handler and every
config file know the node as. It is also, on this deployment, a name its owner
chose — a name like `<site>-<board>` names a machine, and a run of them names a fleet's
naming convention — so printing it on a public map hands a stranger a
correlation key the node's owner never agreed to publish. `core.nodes.Node`
already carries the answer: `node_ref`, minted at registration
(`services.node_auth.mint_node_ref`) as `"nde" + 12` base36 characters,
deliberately rotatable without reflashing the board.

The gap this module closes is that most live nodes have no row. The `nodes`
table only holds nodes that registered through `/v1/nodes`; the blah2 bridge's
receivers and anything speaking the plain TCP protocol never do, and on the
test deployment (2026-09-06) that is all seven of them. So a ref has to exist
for an unregistered node too, and it has to be indistinguishable from a minted
one — a map where some nodes show `ndeXXXXXXXXXXXX` and the rest show their
operator-chosen names publishes exactly the ids it was trying not to.

For those, the ref is derived: `HMAC-SHA256(fuzz salt, "node_ref|" + node_id)`
rendered in the same base36 alphabet, truncated to the same 12 characters.

- The salt is `services.public_location._salt()` — the configured
`NODE_FUZZ_SALT`, else the persisted runtime salt. It is already this
deployment's anonymity key, it is secret, and it survives restarts, so a
derived ref is stable for the life of the deployment without a table to
store it in. A deployment that rotates the salt re-anonymises its nodes,
which is the same thing rotating it already does to their positions.
- The `node_ref|` domain prefix separates this HMAC frame from the location
one (`public_location._frame_message`, which hashes a bare identity or an
identity plus a bounds label). Two frames sharing a key must not be able to
spell each other's messages, or a published ref would be a published sample
of the offset key.
- There is no fallback to the raw id. A node whose ref cannot be looked up is
derived; derivation needs only the salt, and the salt always resolves (see
`_persisted_salt`). "Publish the id when something goes wrong" would make
the leak conditional on a database being down, which is precisely when
nobody is watching.

Registered nodes still win: their stored `node_ref` is the handle the rest of
the node API and the dashboard already use, and deriving a second one for them
would publish two names for one node.
"""

from __future__ import annotations

import hashlib
import hmac
import logging
import threading
import time

logger = logging.getLogger(__name__)

# How long a snapshot of the nodes table is reused. Refs change only when a
# node registers or is rotated, both human-speed events, and the alternative is
# a database round trip per published node per refresh cycle. Mirrors
# services/node_sites.py, which caches the same kind of thing for the same
# reason.
_TTL_S = 30.0

# After a failed refresh, retry sooner than the full TTL — the snapshot is
# older than it should be, not wrong.
_ERROR_RETRY_S = 5.0

_PREFIX = "nde"
_REF_CHARS = 12 # same shape as mint_node_ref, so the two are not tellable apart

# Domain separator for the derivation HMAC. "|" cannot start a node id, and
# the location frame's messages never begin with this literal, so no node id
# can spell a message in the other frame.
_DOMAIN = "node_ref|"

_lock = threading.Lock()
# node_id -> stored node_ref, from the last successful read of the nodes table.
_db_refs: dict[str, str] = {}
_expires_at: float = 0.0
# (salt, node_id) -> derived ref. Keyed on the salt so a re-salted deployment
# (or a test that monkeypatches it) cannot read a stale ref back out — the same
# reason public_location keys its offset cache on the salt. Bounded by the
# number of nodes this process has ever published.
_derived: dict[tuple[str, str], str] = {}
# Whether the "no node table" case has been logged. It is the normal state of
# a deployment whose nodes all predate /v1/nodes, so it is worth saying once
# and worth never saying again.
_db_unavailable_logged = False


def _reset_for_tests() -> None:
"""Drop the snapshot and the derived refs. Tests only."""
global _expires_at, _db_unavailable_logged
with _lock:
_db_refs.clear()
_derived.clear()
_expires_at = 0.0
_db_unavailable_logged = False


def _refs_from_db() -> dict[str, str]:
"""{node_id: node_ref} for every registered node.

Imported inside the function and driven by the synchronous-engine pattern
services/publication.py documents, for the reasons services/node_sites.py
gives: the callers are executor threads and route handlers, neither of
which can await, and this module must import cleanly in a process with no
database at all.
"""
from sqlalchemy import select

from core.nodes import Node
from services.publication import _sync_engine

with _sync_engine().connect() as conn:
rows = conn.execute(select(Node.node_id, Node.node_ref))
return {node_id: ref for node_id, ref in rows if node_id and ref}


def _snapshot() -> dict[str, str]:
global _expires_at, _db_unavailable_logged
now = time.monotonic()
if now < _expires_at:
return _db_refs
with _lock:
if time.monotonic() < _expires_at:
return _db_refs
try:
# Fetch first, swap second: a failed read leaves the previous
# snapshot in place rather than an empty one, so a transient
# database blip cannot flip a registered node to a derived name
# and back (services/node_sites.py keeps its snapshot the same
# way). The stale answer is the last good answer, not a wrong one.
fresh = _refs_from_db()
_db_refs.clear()
_db_refs.update(fresh)
_expires_at = time.monotonic() + _TTL_S
except Exception:
# No database, no table, or a transient failure. With no snapshot
# yet, every node derives its ref, which is the answer for an
# unregistered node anyway — so this degrades to "nothing is
# registered", never to publishing an id.
if not _db_unavailable_logged:
_db_unavailable_logged = True
logger.exception("node_ref: no node registry available, deriving every public ref")
_expires_at = time.monotonic() + _ERROR_RETRY_S
return _db_refs


def _derived_ref(node_id: str) -> str:
"""The HMAC-derived ref for a node with no registry row."""
from services.node_auth import _ALPHABET
from services.public_location import _salt

salt = _salt()
key = (salt, node_id)
cached = _derived.get(key)
if cached is not None:
return cached

digest = hmac.new(salt.encode("utf-8"), (_DOMAIN + node_id).encode("utf-8"), hashlib.sha256).digest()
# Base36 over the digest as one big integer, least-significant digit first.
# Twelve characters is ~62 bits of the 256 available, the same width — and
# the same alphabet — mint_node_ref draws at random.
value = int.from_bytes(digest, "big")
base = len(_ALPHABET)
chars = []
for _ in range(_REF_CHARS):
value, remainder = divmod(value, base)
chars.append(_ALPHABET[remainder])
ref = _PREFIX + "".join(chars)
_derived[key] = ref
return ref


def public_node_ref(node_id: str) -> str:
"""The public handle for a node: its registered ref, else a derived one.

Never the node id. A missing id derives from the empty string rather than
passing through, for the reason public_offset_km hashes it: a missing id is
a config fault, and the safe reading of a config fault is not to publish
whatever was there.
"""
return _snapshot().get(node_id) or _derived_ref(node_id or "")
14 changes: 13 additions & 1 deletion backend/services/tasks/analytics_refresh.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from services.geo import valid_latlon as _valid_latlon
from services.id_utils import multinode_hex_from_key
from services.node_config import position_status
from services.node_ref import public_node_ref
from services.node_sites import log_colocation_audit
from services.public_geometry import without_receiver_geometry
from services.public_location import (
Expand Down Expand Up @@ -333,8 +334,18 @@ def _refresh_analytics_and_nodes():
# public_summaries drops it before public_node_summaries rewrites what is
# left. Two separate promises, applied in the order they compose — there
# is nothing to translate for a node that is not being published.
# The handle the map is allowed to print. Added once, before the real-only
# split, so both variants carry it: a node id names a machine its owner
# chose the name of, and every surface that shows a node has to have
# something else to show. See services/node_ref.py. New dicts, not an
# in-place key: with the fuzz off, public_node_summaries hands back the
# manager's own cached summaries, and those are not ours to grow.
public_nodes = {
nid: {**summary, "node_ref": public_node_ref(nid)}
for nid, summary in public_node_summaries(public_summaries(state.node_analytics.get_all_summaries())).items()
}
analytics_data = {
"nodes": public_node_summaries(public_summaries(state.node_analytics.get_all_summaries())),
"nodes": public_nodes,
"cross_node": public_cross_node(state.node_analytics.get_cross_node_analysis()),
}
state.latest_analytics_bytes = orjson.dumps(analytics_data, option=orjson.OPT_SERIALIZE_NUMPY)
Expand Down Expand Up @@ -371,6 +382,7 @@ def _refresh_analytics_and_nodes():
"nodes": {
nid: {
"status": info.get("status"),
"node_ref": public_node_ref(nid),
"name": info.get("config", {}).get("name", nid),
"config_hash": info.get("config_hash"),
"last_heartbeat": info.get("last_heartbeat"),
Expand Down
52 changes: 52 additions & 0 deletions backend/tests/test_analytics_refresh.py
Original file line number Diff line number Diff line change
Expand Up @@ -413,3 +413,55 @@ def test_a_stale_claim_still_reads_as_missed(self):
assert entry["detected"] == 0
assert entry["missed"] == 1
assert entry["miss_rate"] == 1.0


# ── Public node identifiers ──────────────────────────────────────────────────


class TestNodeRefInPublicPayloads:
"""Every payload that names a node carries its public handle.

The map has to print something for a node, and the node id is the name its
owner gave the machine — see services/node_ref.py. Added before the
real-only split, so both analytics variants and /api/radar/nodes agree.
"""

NODE = "test-noderef-1"

@pytest.fixture(autouse=True)
def _a_connected_node(self):
from core import state

state.node_analytics.register_node(self.NODE, {"rx_lat": 33.45, "rx_lon": -112.07, "max_range_km": 50})
state.connected_nodes[self.NODE] = {
"status": "connected",
"config": {"name": "noderef-test", "rx_lat": 33.45, "rx_lon": -112.07},
"is_synthetic": False,
}
yield
state.connected_nodes.pop(self.NODE, None)
state.node_analytics.retire_node(self.NODE)

def _refresh(self):
from services.tasks.analytics_refresh import _refresh_analytics_and_nodes

_refresh_analytics_and_nodes()

def test_both_analytics_variants_carry_it(self):
from core import state
from services.node_ref import public_node_ref

self._refresh()
expected = public_node_ref(self.NODE)
for raw in (state.latest_analytics_bytes, state.latest_analytics_real_bytes):
node = orjson.loads(raw)["nodes"][self.NODE]
assert node["node_ref"] == expected
assert node["node_ref"] != self.NODE

def test_the_nodes_payload_carries_it(self):
from core import state
from services.node_ref import public_node_ref

self._refresh()
node = orjson.loads(state.latest_nodes_bytes)["nodes"][self.NODE]
assert node["node_ref"] == public_node_ref(self.NODE)
Loading
Loading