mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-08-04 07:52:59 +02:00
Merge pull request #168 from ipnet-mesh/fix/public-key-casing
fix: normalize public keys to lowercase to prevent tag/event mismatches
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
"""Normalize public_key to lowercase and merge duplicate nodes
|
||||
|
||||
Revision ID: b1c2d3e4f5a6
|
||||
Revises: a1b2c3d4e5f6
|
||||
Create Date: 2026-04-21
|
||||
|
||||
Before this migration, public_key values could be stored in mixed case:
|
||||
- tag_import.py stored them as lowercase (via validate_public_key)
|
||||
- letsmesh_normalizer.py stored them as UPPERCASE (via _normalize_full_public_key)
|
||||
- MQTT topic paths stored them as-is
|
||||
|
||||
This caused duplicate nodes for the same physical device, with tags
|
||||
linked to one and mesh events linked to another.
|
||||
|
||||
This migration:
|
||||
1. Merges duplicate nodes (picking the one with the earliest first_seen)
|
||||
2. Re-points all FK references to the winner node
|
||||
3. Deletes the loser nodes
|
||||
4. Normalizes all remaining public_keys to lowercase
|
||||
5. Also lowercases public_key columns in child tables (advertisements, telemetry)
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
from sqlalchemy import text
|
||||
|
||||
revision = "b1c2d3e4f5a6"
|
||||
down_revision = "a1b2c3d4e5f6"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# Find groups of duplicate nodes (same lowercase public_key, different actual case)
|
||||
duplicates = conn.execute(text("""
|
||||
SELECT LOWER(public_key) AS lower_pk,
|
||||
GROUP_CONCAT(id) AS ids,
|
||||
COUNT(*) AS cnt
|
||||
FROM nodes
|
||||
GROUP BY LOWER(public_key)
|
||||
HAVING cnt > 1
|
||||
""")).fetchall()
|
||||
|
||||
for row in duplicates:
|
||||
lower_pk = row[0]
|
||||
ids = row[1].split(",")
|
||||
|
||||
winner_row = conn.execute(
|
||||
text("""
|
||||
SELECT id FROM nodes
|
||||
WHERE LOWER(public_key) = :lower_pk
|
||||
ORDER BY first_seen ASC, created_at ASC
|
||||
LIMIT 1
|
||||
"""),
|
||||
{"lower_pk": lower_pk},
|
||||
).fetchone()
|
||||
|
||||
if not winner_row:
|
||||
continue
|
||||
|
||||
winner_id = winner_row[0]
|
||||
loser_ids = [nid for nid in ids if nid != winner_id]
|
||||
|
||||
for loser_id in loser_ids:
|
||||
params = {"winner_id": winner_id, "loser_id": loser_id}
|
||||
|
||||
# For CASCADE tables, move records to winner first
|
||||
conn.execute(
|
||||
text(
|
||||
"UPDATE node_tags SET node_id = :winner_id WHERE node_id = :loser_id"
|
||||
),
|
||||
params,
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"UPDATE event_observers SET observer_node_id = :winner_id WHERE observer_node_id = :loser_id"
|
||||
),
|
||||
params,
|
||||
)
|
||||
|
||||
# For SET NULL tables, re-point to winner instead of losing the link
|
||||
conn.execute(
|
||||
text(
|
||||
"UPDATE advertisements SET observer_node_id = :winner_id WHERE observer_node_id = :loser_id"
|
||||
),
|
||||
params,
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"UPDATE advertisements SET node_id = :winner_id WHERE node_id = :loser_id"
|
||||
),
|
||||
params,
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"UPDATE telemetry SET observer_node_id = :winner_id WHERE observer_node_id = :loser_id"
|
||||
),
|
||||
params,
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"UPDATE telemetry SET node_id = :winner_id WHERE node_id = :loser_id"
|
||||
),
|
||||
params,
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"UPDATE messages SET observer_node_id = :winner_id WHERE observer_node_id = :loser_id"
|
||||
),
|
||||
params,
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"UPDATE trace_paths SET observer_node_id = :winner_id WHERE observer_node_id = :loser_id"
|
||||
),
|
||||
params,
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"UPDATE events_log SET observer_node_id = :winner_id WHERE observer_node_id = :loser_id"
|
||||
),
|
||||
params,
|
||||
)
|
||||
|
||||
# Merge scalar fields into winner (keep best data)
|
||||
conn.execute(
|
||||
text("""
|
||||
UPDATE nodes SET
|
||||
name = COALESCE(name, (SELECT name FROM nodes WHERE id = :loser_id)),
|
||||
adv_type = COALESCE(adv_type, (SELECT adv_type FROM nodes WHERE id = :loser_id)),
|
||||
last_seen = (
|
||||
SELECT MAX(v) FROM (
|
||||
SELECT last_seen AS v FROM nodes WHERE id = :winner_id
|
||||
UNION ALL
|
||||
SELECT last_seen AS v FROM nodes WHERE id = :loser_id
|
||||
)
|
||||
),
|
||||
lat = COALESCE(lat, (SELECT lat FROM nodes WHERE id = :loser_id)),
|
||||
lon = COALESCE(lon, (SELECT lon FROM nodes WHERE id = :loser_id))
|
||||
WHERE id = :winner_id
|
||||
"""),
|
||||
params,
|
||||
)
|
||||
|
||||
# Delete the loser (CASCADE will clean up any remaining orphans)
|
||||
conn.execute(
|
||||
text("DELETE FROM nodes WHERE id = :loser_id"), {"loser_id": loser_id}
|
||||
)
|
||||
|
||||
# Now normalize all remaining public_keys to lowercase
|
||||
conn.execute(text("UPDATE nodes SET public_key = LOWER(public_key)"))
|
||||
|
||||
# Also lowercase public_key in child tables that store their own copy
|
||||
conn.execute(text("UPDATE advertisements SET public_key = LOWER(public_key)"))
|
||||
conn.execute(text("UPDATE telemetry SET node_public_key = LOWER(node_public_key)"))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Cannot reverse the merge (duplicate data has been deleted).
|
||||
# The public_key normalization to lowercase is also irreversible
|
||||
# since we don't know the original case.
|
||||
pass
|
||||
+15
-1
@@ -4,7 +4,7 @@ This guide covers upgrading from a previous MeshCore Hub release to the current
|
||||
|
||||
## v0.9.0
|
||||
|
||||
This release includes **breaking changes** to the MQTT broker, packet capture service, and data ingestion pipeline.
|
||||
This release includes **breaking changes** to the MQTT broker, packet capture service, data ingestion pipeline, and public key handling.
|
||||
|
||||
### Overview of Changes
|
||||
|
||||
@@ -23,6 +23,20 @@ This release includes **breaking changes** to the MQTT broker, packet capture se
|
||||
| Compose files | Single `docker-compose.yml` | Base + environment overrides (`.dev.yml`, `.prod.yml`) |
|
||||
| Container names | `meshcore-*` | Parameterized via `COMPOSE_PROJECT_NAME` (default: `hub-*`) |
|
||||
| Volume names | `meshcore_*` | Parameterized via `COMPOSE_PROJECT_NAME` (default: `hub_*`) |
|
||||
| Public key case | Mixed (uppercase/lowercase) | Normalized to **lowercase** |
|
||||
|
||||
### Public Key Case Normalization
|
||||
|
||||
Previously, the tag importer stored `public_key` as lowercase while the LetsMesh packet normalizer stored it as UPPERCASE. This could create duplicate nodes for the same physical device — with tags linked to one node and mesh events linked to another.
|
||||
|
||||
An Alembic migration (`b1c2d3e4f5a6`) automatically:
|
||||
|
||||
1. Merges duplicate nodes (keeping the one with the earliest `first_seen`)
|
||||
2. Re-points all foreign key references to the surviving node
|
||||
3. Deletes the duplicate node
|
||||
4. Normalizes all remaining `public_key` values to lowercase
|
||||
|
||||
**No manual action is required** — the migration runs as part of `meshcore-hub db upgrade` (or the `migrate` Docker Compose service).
|
||||
|
||||
### Step 1: Backup
|
||||
|
||||
|
||||
@@ -784,7 +784,7 @@ class LetsMeshNormalizer:
|
||||
match = re.search(r"([0-9A-Fa-f]{64})", value)
|
||||
if not match:
|
||||
return None
|
||||
return match.group(1).upper()
|
||||
return match.group(1).lower()
|
||||
|
||||
@classmethod
|
||||
def _parse_hex_or_int(cls, value: Any) -> int | None:
|
||||
@@ -903,7 +903,7 @@ class LetsMeshNormalizer:
|
||||
return None
|
||||
if any(ch not in "0123456789ABCDEF" for ch in normalized):
|
||||
return None
|
||||
return normalized
|
||||
return normalized.lower()
|
||||
|
||||
@staticmethod
|
||||
def _normalize_pubkey_prefix(value: Any) -> str | None:
|
||||
|
||||
@@ -37,6 +37,12 @@ class Node(Base, UUIDMixin, TimestampMixin):
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
def __init__(self, **kwargs: object) -> None:
|
||||
super().__init__(**kwargs)
|
||||
if self.public_key and isinstance(self.public_key, str):
|
||||
self.public_key = self.public_key.lower()
|
||||
|
||||
name: Mapped[Optional[str]] = mapped_column(
|
||||
String(255),
|
||||
nullable=True,
|
||||
|
||||
@@ -102,7 +102,7 @@ class TopicBuilder:
|
||||
):
|
||||
public_key = parts[prefix_len]
|
||||
event_name = "/".join(parts[prefix_len + 2 :])
|
||||
return (public_key, event_name)
|
||||
return (public_key.lower(), event_name)
|
||||
return None
|
||||
|
||||
def parse_command_topic(self, topic: str) -> tuple[str, str] | None:
|
||||
@@ -124,7 +124,7 @@ class TopicBuilder:
|
||||
):
|
||||
public_key = parts[prefix_len]
|
||||
command_name = "/".join(parts[prefix_len + 2 :])
|
||||
return (public_key, command_name)
|
||||
return (public_key.lower(), command_name)
|
||||
return None
|
||||
|
||||
def parse_letsmesh_upload_topic(self, topic: str) -> tuple[str, str] | None:
|
||||
@@ -145,7 +145,7 @@ class TopicBuilder:
|
||||
if feed_type not in {"packets", "status", "internal"}:
|
||||
return None
|
||||
|
||||
return (public_key, feed_type)
|
||||
return (public_key.lower(), feed_type)
|
||||
|
||||
|
||||
MessageHandler = Callable[[str, str, dict[str, Any]], None]
|
||||
|
||||
@@ -43,8 +43,10 @@ def _make_decoder(
|
||||
return decoder
|
||||
|
||||
|
||||
PUB_KEY = "AA" * 32
|
||||
OBSERVER_KEY = "BB" * 32
|
||||
PUB_KEY_UPPER = "AA" * 32
|
||||
PUB_KEY = PUB_KEY_UPPER.lower()
|
||||
OBSERVER_KEY_UPPER = "BB" * 32
|
||||
OBSERVER_KEY = OBSERVER_KEY_UPPER.lower()
|
||||
|
||||
|
||||
class TestStatusFeed:
|
||||
@@ -90,7 +92,7 @@ class TestAdvertPacket:
|
||||
"payload": {
|
||||
"decoded": {
|
||||
"type": 4,
|
||||
"publicKey": PUB_KEY,
|
||||
"publicKey": PUB_KEY_UPPER,
|
||||
"timestamp": 1700000000,
|
||||
"signature": "CC" * 64,
|
||||
"appData": {
|
||||
@@ -130,7 +132,7 @@ class TestAdvertPacket:
|
||||
"payload": {
|
||||
"decoded": {
|
||||
"type": 4,
|
||||
"publicKey": PUB_KEY,
|
||||
"publicKey": PUB_KEY_UPPER,
|
||||
"timestamp": 1700000000,
|
||||
"signature": "CC" * 64,
|
||||
}
|
||||
@@ -346,10 +348,10 @@ class TestControlPacket:
|
||||
"flags": 1,
|
||||
"subType": 144,
|
||||
"dataHex": "",
|
||||
"publicKey": PUB_KEY,
|
||||
"publicKey": PUB_KEY_UPPER,
|
||||
"nodeType": 2,
|
||||
"parsed": {
|
||||
"publicKey": PUB_KEY,
|
||||
"publicKey": PUB_KEY_UPPER,
|
||||
"nodeType": 2,
|
||||
"snr": 10,
|
||||
},
|
||||
@@ -411,7 +413,7 @@ class TestPathPacket:
|
||||
"pathLength": 4,
|
||||
"pathHashes": ["AA", "BB", "CC", "DD"],
|
||||
"extraType": 1,
|
||||
"extraData": PUB_KEY,
|
||||
"extraData": PUB_KEY_UPPER,
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -447,7 +449,7 @@ class TestResponsePacket:
|
||||
"tag": 0,
|
||||
"decrypted": {
|
||||
"content": {
|
||||
"node_public_key": PUB_KEY,
|
||||
"node_public_key": PUB_KEY_UPPER,
|
||||
"battery_voltage": 3.7,
|
||||
"battery_percentage": 85,
|
||||
}
|
||||
@@ -484,7 +486,7 @@ class TestResponsePacket:
|
||||
"tag": 0,
|
||||
"decrypted": {
|
||||
"content": {
|
||||
"node_public_key": PUB_KEY,
|
||||
"node_public_key": PUB_KEY_UPPER,
|
||||
"parsed_data": {"temperature": 22.5, "humidity": 60},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -493,7 +493,7 @@ class TestSubscriber:
|
||||
public_key, event_type, payload, _db = handler.call_args.args
|
||||
assert public_key == "a" * 64
|
||||
assert event_type == "advertisement"
|
||||
assert payload["public_key"] == "B" * 64
|
||||
assert payload["public_key"] == "b" * 64
|
||||
assert payload["name"] == "Concord Attic G2"
|
||||
assert payload["adv_type"] == "repeater"
|
||||
assert payload["flags"] == 146
|
||||
@@ -548,7 +548,7 @@ class TestSubscriber:
|
||||
contact_handler.assert_called_once()
|
||||
_public_key, event_type, payload, _db = contact_handler.call_args.args
|
||||
assert event_type == "contact"
|
||||
assert payload["public_key"] == "C" * 64
|
||||
assert payload["public_key"] == "c" * 64
|
||||
assert payload["type"] == 2
|
||||
assert payload["flags"] == 146
|
||||
|
||||
@@ -755,7 +755,7 @@ class TestSubscriber:
|
||||
assert payload["hop_count"] == 2
|
||||
assert payload["path_hashes"] == ["AA", "BB"]
|
||||
assert payload["extra_type"] == 244
|
||||
assert payload["node_public_key"] == "D" * 64
|
||||
assert payload["node_public_key"] == "d" * 64
|
||||
|
||||
def test_letsmesh_packet_fallback_logs_decoded_payload(
|
||||
self, mock_mqtt_client, db_manager
|
||||
|
||||
@@ -14,7 +14,7 @@ class TestTopicBuilder:
|
||||
"meshcore/ABCDEF1234567890/event/advertisement"
|
||||
)
|
||||
|
||||
assert parsed == ("ABCDEF1234567890", "advertisement")
|
||||
assert parsed == ("abcdef1234567890", "advertisement")
|
||||
|
||||
def test_parse_event_topic_with_multi_segment_prefix(self) -> None:
|
||||
"""Event topics are parsed correctly with a slash-delimited prefix."""
|
||||
@@ -24,7 +24,7 @@ class TestTopicBuilder:
|
||||
"meshcore/BOS/ABCDEF1234567890/event/channel_msg_recv"
|
||||
)
|
||||
|
||||
assert parsed == ("ABCDEF1234567890", "channel_msg_recv")
|
||||
assert parsed == ("abcdef1234567890", "channel_msg_recv")
|
||||
|
||||
def test_parse_command_topic_with_multi_segment_prefix(self) -> None:
|
||||
"""Command topics are parsed correctly with a slash-delimited prefix."""
|
||||
@@ -34,7 +34,7 @@ class TestTopicBuilder:
|
||||
"meshcore/BOS/ABCDEF123456/command/send_msg"
|
||||
)
|
||||
|
||||
assert parsed == ("ABCDEF123456", "send_msg")
|
||||
assert parsed == ("abcdef123456", "send_msg")
|
||||
|
||||
def test_parse_letsmesh_upload_topic(self) -> None:
|
||||
"""LetsMesh upload topics map to public key and feed type."""
|
||||
@@ -44,7 +44,7 @@ class TestTopicBuilder:
|
||||
"meshcore/STN/ABCDEF1234567890/status"
|
||||
)
|
||||
|
||||
assert parsed == ("ABCDEF1234567890", "status")
|
||||
assert parsed == ("abcdef1234567890", "status")
|
||||
|
||||
def test_parse_letsmesh_upload_topic_rejects_unknown_feed(self) -> None:
|
||||
"""Unknown LetsMesh feed topics are rejected."""
|
||||
|
||||
Reference in New Issue
Block a user