mirror of
https://github.com/pablorevilla-meshtastic/meshview.git
synced 2026-10-31 03:01:58 +01:00
Include the node's public key in the contact QR code (#185)
* Include the node's public key in the contact QR code #184 The contact QR code and import URL never set user.public_key. Current Meshtastic apps refuse to import a keyless contact. On 2.7.x firmware, an older app's add_contact also overwrites the key the radio already holds, so direct messages to that node fail. Read the key from the node's latest stored NODEINFO_APP packet. Share the contact only when a 32-byte key is known. Otherwise the node page hides the QR buttons and says why. The QR API response now also returns public_key. Also drop the stray blank line that fails ruff format --check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Do not share a contact when a node has reported more than one key #184 A forged NODEINFO uplinked over MQTT could otherwise become the newest packet, and the shared contact would then replace the key a scanning radio already trusts. The QR endpoint now reads every stored NODEINFO from the node and refuses to share when their 32-byte keys disagree. It reports that in public_key_conflict, and the node page says why. Add an index on packet (from_node_id, portnum, import_time_us DESC) so the lookup reads only the node's NODEINFO packets. Order with NULLS LAST so PostgreSQL does not treat old rows without import_time_us as newest. Carry is_licensed and is_unmessagable into the contact. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Skip NODEINFO rows with no payload when building the contact #184 packet.payload is nullable, and MeshPacket.FromString(None) raises TypeError, so one such row made the QR endpoint answer 500. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
aabb099468
commit
ae7ba15263
8 files changed
+133
-15
No files matched your search
@@ -0,0 +1,37 @@
|
||||
"""Add packet index on from_node_id, portnum and import_time_us
|
||||
|
||||
Revision ID: 4d2a7c9e1b30
|
||||
Revises: e8f2c4b6d9a1
|
||||
Create Date: 2026-10-26 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "4d2a7c9e1b30"
|
||||
down_revision: str | None = "e8f2c4b6d9a1"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
INDEX_NAME = "idx_packet_from_node_portnum_time_us"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Lets the node QR endpoint read a node's NODEINFO packets without scanning all its packets.
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if INDEX_NAME not in {index["name"] for index in inspector.get_indexes("packet")}:
|
||||
op.create_index(
|
||||
INDEX_NAME,
|
||||
"packet",
|
||||
["from_node_id", "portnum", sa.text("import_time_us DESC")],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(INDEX_NAME, table_name="packet")
|
||||
@@ -246,7 +246,9 @@
|
||||
"share_contact_qr": "Share Contact QR",
|
||||
"copy_url": "Copy URL",
|
||||
"copied": "Copied!",
|
||||
"scan_qr_to_add": "Scan this QR code to add this node as a contact on another device."
|
||||
"scan_qr_to_add": "Scan this QR code to add this node as a contact on another device.",
|
||||
"qr_no_public_key": "No public key heard from this node yet, so it cannot be shared as a contact.",
|
||||
"qr_key_conflict": "This node has reported more than one public key, so it is not shared as a contact."
|
||||
},
|
||||
"packet": {
|
||||
"loading": "Loading packet information...",
|
||||
|
||||
@@ -232,7 +232,9 @@
|
||||
"share_contact_qr": "Compartir contacto QR",
|
||||
"copy_url": "Copiar URL",
|
||||
"copied": "¡Copiado!",
|
||||
"scan_qr_to_add": "Escanea este código QR para agregar este nodo como contacto en otro dispositivo."
|
||||
"scan_qr_to_add": "Escanea este código QR para agregar este nodo como contacto en otro dispositivo.",
|
||||
"qr_no_public_key": "Aún no se ha recibido la clave pública de este nodo, por lo que no se puede compartir como contacto.",
|
||||
"qr_key_conflict": "Este nodo ha informado más de una clave pública, por lo que no se comparte como contacto."
|
||||
},
|
||||
|
||||
"packet": {
|
||||
|
||||
@@ -227,7 +227,9 @@
|
||||
"share_contact_qr": "Поделиться QR контакта",
|
||||
"copy_url": "Копировать URL",
|
||||
"copied": "Скопировано!",
|
||||
"scan_qr_to_add": "Отсканируйте этот QR-код, чтобы добавить эту ноду в качестве контакта на другом устройстве."
|
||||
"scan_qr_to_add": "Отсканируйте этот QR-код, чтобы добавить эту ноду в качестве контакта на другом устройстве.",
|
||||
"qr_no_public_key": "Открытый ключ этой ноды ещё не получен, поэтому её нельзя отправить как контакт.",
|
||||
"qr_key_conflict": "Эта нода сообщила более одного открытого ключа, поэтому она не отправляется как контакт."
|
||||
},
|
||||
"packet": {
|
||||
"loading": "Загрузка информации о пакете...",
|
||||
|
||||
@@ -59,6 +59,12 @@ class Packet(Base):
|
||||
Index("idx_packet_to_node_id", "to_node_id"),
|
||||
Index("idx_packet_import_time_us", desc("import_time_us")),
|
||||
Index("idx_packet_from_node_time_us", "from_node_id", desc("import_time_us")),
|
||||
Index(
|
||||
"idx_packet_from_node_portnum_time_us",
|
||||
"from_node_id",
|
||||
"portnum",
|
||||
desc("import_time_us"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -113,6 +113,17 @@ async def get_packets_from(node_id=None, portnum=None, since=None, limit=500):
|
||||
return result.scalars()
|
||||
|
||||
|
||||
async def get_nodeinfo_payloads(node_id):
|
||||
"""Return the payloads of every stored NODEINFO_APP packet from a node, newest first."""
|
||||
async with database.async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Packet.payload)
|
||||
.where(Packet.from_node_id == node_id, Packet.portnum == PortNum.NODEINFO_APP)
|
||||
.order_by(Packet.import_time_us.desc().nulls_last())
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_packet(packet_id):
|
||||
async with database.async_session() as session:
|
||||
q = select(Packet).where(Packet.id == packet_id)
|
||||
|
||||
@@ -173,6 +173,11 @@
|
||||
border-color: #48bb78;
|
||||
color: #fff;
|
||||
}
|
||||
.node-actions .qr-unavailable {
|
||||
align-self: center;
|
||||
color: #b0bec5;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.copy-success {
|
||||
color: #4ade80 !important;
|
||||
transition: opacity 0.3s;
|
||||
@@ -315,6 +320,12 @@
|
||||
<button onclick="showQrCode()" id="showQrBtn">
|
||||
<span>🔳</span> <span data-translate-lang="show_qr_code">Show QR Code</span>
|
||||
</button>
|
||||
<span class="qr-unavailable" id="qrUnavailable" style="display:none;" data-translate-lang="qr_no_public_key">
|
||||
No public key heard from this node yet, so it cannot be shared as a contact.
|
||||
</span>
|
||||
<span class="qr-unavailable" id="qrKeyConflict" style="display:none;" data-translate-lang="qr_key_conflict">
|
||||
This node has reported more than one public key, so it is not shared as a contact.
|
||||
</span>
|
||||
<button onclick="openReachReport()" id="reachReportBtn">
|
||||
<span>📈</span> <span>Reliability</span>
|
||||
</button>
|
||||
@@ -1811,8 +1822,16 @@ async function loadNodeQr() {
|
||||
try {
|
||||
const qrRes = await fetch(`/api/node/${fromNodeId}/qr`);
|
||||
const qrData = await qrRes.json();
|
||||
if (qrRes.ok && qrData.meshtastic_url) {
|
||||
currentMeshtasticUrl = qrData.meshtastic_url;
|
||||
if (qrRes.ok) {
|
||||
// No URL means no usable public key: none is known, or the node reported several.
|
||||
currentMeshtasticUrl = qrData.meshtastic_url || "";
|
||||
const canShare = Boolean(currentMeshtasticUrl);
|
||||
const keyConflict = Boolean(qrData.public_key_conflict);
|
||||
document.getElementById("copyUrlBtn").style.display = canShare ? "" : "none";
|
||||
document.getElementById("showQrBtn").style.display = canShare ? "" : "none";
|
||||
document.getElementById("qrUnavailable").style.display =
|
||||
canShare || keyConflict ? "none" : "";
|
||||
document.getElementById("qrKeyConflict").style.display = keyConflict ? "" : "none";
|
||||
actionsDiv.style.display = "flex";
|
||||
} else {
|
||||
actionsDiv.style.display = "none";
|
||||
|
||||
+49
-10
@@ -744,7 +744,6 @@ async def api_config(request):
|
||||
"map_bottom_right_lat": get_float(site, "map_bottom_right_lat", 36.0),
|
||||
"map_bottom_right_lon": get_float(site, "map_bottom_right_lon", -121.0),
|
||||
"map_interval": get_int(site, "map_interval", 3),
|
||||
|
||||
"map_channel_colors": {
|
||||
channel: color.strip()
|
||||
for channel, color in CONFIG.get("map_channel_colors", {}).items()
|
||||
@@ -1173,9 +1172,11 @@ async def api_node_qr(request):
|
||||
return web.json_response({"error": "Node not found"}, status=404)
|
||||
|
||||
try:
|
||||
from google.protobuf.message import DecodeError
|
||||
|
||||
from meshtastic.protobuf.admin_pb2 import SharedContact
|
||||
from meshtastic.protobuf.config_pb2 import Config
|
||||
from meshtastic.protobuf.mesh_pb2 import User
|
||||
from meshtastic.protobuf.mesh_pb2 import MeshPacket, User
|
||||
|
||||
user = User()
|
||||
user.id = f"!{node_id:08x}"
|
||||
@@ -1200,18 +1201,52 @@ async def api_node_qr(request):
|
||||
except (AttributeError, TypeError):
|
||||
pass
|
||||
|
||||
contact = SharedContact()
|
||||
contact.node_num = node_id
|
||||
contact.user.CopyFrom(user)
|
||||
contact.manually_verified = False
|
||||
# Meshview does not store public keys, so read them from the node's NODEINFO packets.
|
||||
latest_nodeinfo = None
|
||||
public_keys = set()
|
||||
for payload in await store.get_nodeinfo_payloads(node_id):
|
||||
if not payload:
|
||||
continue
|
||||
try:
|
||||
mesh_packet = MeshPacket.FromString(payload)
|
||||
except DecodeError:
|
||||
continue
|
||||
nodeinfo = decode_payload.decode_payload(
|
||||
PortNum.NODEINFO_APP, mesh_packet.decoded.payload
|
||||
)
|
||||
if not isinstance(nodeinfo, User):
|
||||
continue
|
||||
if latest_nodeinfo is None:
|
||||
latest_nodeinfo = nodeinfo
|
||||
if len(nodeinfo.public_key) == 32:
|
||||
public_keys.add(nodeinfo.public_key)
|
||||
|
||||
if latest_nodeinfo is not None:
|
||||
if len(latest_nodeinfo.public_key) == 32:
|
||||
user.public_key = latest_nodeinfo.public_key
|
||||
user.is_licensed = latest_nodeinfo.is_licensed
|
||||
if latest_nodeinfo.HasField("is_unmessagable"):
|
||||
user.is_unmessagable = latest_nodeinfo.is_unmessagable
|
||||
|
||||
contact_bytes = contact.SerializeToString()
|
||||
import base64
|
||||
|
||||
contact_b64 = base64.b64encode(contact_bytes).decode("ascii")
|
||||
contact_b64url = contact_b64.replace("+", "-").replace("/", "_").rstrip("=")
|
||||
# A contact without a public key is refused by current Meshtastic apps and, on older
|
||||
# firmware, erases the key the importing radio already has. Importing a contact also
|
||||
# replaces a key the radio has pinned, so when the node's packets disagree on its key
|
||||
# (one of them may be forged), share neither.
|
||||
public_key_conflict = len(public_keys) > 1
|
||||
meshtastic_url = None
|
||||
if user.public_key and not public_key_conflict:
|
||||
contact = SharedContact()
|
||||
contact.node_num = node_id
|
||||
contact.user.CopyFrom(user)
|
||||
contact.manually_verified = False
|
||||
|
||||
meshtastic_url = f"https://meshtastic.org/v/#{contact_b64url}"
|
||||
contact_bytes = contact.SerializeToString()
|
||||
contact_b64 = base64.b64encode(contact_bytes).decode("ascii")
|
||||
contact_b64url = contact_b64.replace("+", "-").replace("/", "_").rstrip("=")
|
||||
|
||||
meshtastic_url = f"https://meshtastic.org/v/#{contact_b64url}"
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
@@ -1219,6 +1254,10 @@ async def api_node_qr(request):
|
||||
"long_name": node.long_name,
|
||||
"short_name": node.short_name,
|
||||
"role": node.role,
|
||||
"public_key": (
|
||||
base64.b64encode(user.public_key).decode("ascii") if user.public_key else None
|
||||
),
|
||||
"public_key_conflict": public_key_conflict,
|
||||
"meshtastic_url": meshtastic_url,
|
||||
}
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user