Merge PR 124: QR/import, impersonation detection, node public keys

This commit is contained in:
pablorevilla-meshtastic
2026-02-05 18:02:49 -08:00
parent c454f2ef3a
commit 82ff4bb0df
10 changed files with 561 additions and 18 deletions

View File

@@ -0,0 +1,43 @@
"""Add node_public_key table
Revision ID: a0c9c13e118f
Revises: d4d7b0c2e1a4
Create Date: 2026-02-06 00:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "a0c9c13e118f"
down_revision: str | None = "d4d7b0c2e1a4"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"node_public_key",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("node_id", sa.BigInteger(), nullable=False),
sa.Column("public_key", sa.String(), nullable=False),
sa.Column("first_seen_us", sa.BigInteger(), nullable=True),
sa.Column("last_seen_us", sa.BigInteger(), nullable=True),
)
op.create_index("idx_node_public_key_node_id", "node_public_key", ["node_id"], unique=False)
op.create_index(
"idx_node_public_key_public_key",
"node_public_key",
["public_key"],
unique=False,
)
def downgrade() -> None:
op.drop_index("idx_node_public_key_public_key", table_name="node_public_key")
op.drop_index("idx_node_public_key_node_id", table_name="node_public_key")
op.drop_table("node_public_key")

View File

@@ -185,7 +185,14 @@
"statistics": "Statistics",
"last_24h": "24h",
"packets_sent": "Packets sent",
"times_seen": "Times seen"
"times_seen": "Times seen",
"copy_import_url": "Copy Import URL",
"show_qr_code": "Show QR Code",
"share_contact_qr": "Share Contact QR",
"copy_url": "Copy URL",
"copied": "Copied!",
"potential_impersonation": "Potential Impersonation Detected",
"scan_qr_to_add": "Scan this QR code to add this node as a contact on another device."
},
"packet": {
"loading": "Loading packet information...",

View File

@@ -171,7 +171,14 @@
"statistics": "Estadísticas",
"last_24h": "24h",
"packets_sent": "Paquetes enviados",
"times_seen": "Veces visto"
"times_seen": "Veces visto",
"copy_import_url": "Copiar URL de importación",
"show_qr_code": "Mostrar código QR",
"share_contact_qr": "Compartir contacto QR",
"copy_url": "Copiar URL",
"copied": "¡Copiado!",
"potential_impersonation": "Posible suplantación detectada",
"scan_qr_to_add": "Escanea este código QR para agregar este nodo como contacto en otro dispositivo."
},
"packet": {

View File

@@ -100,7 +100,21 @@ class Traceroute(Base):
import_time_us: Mapped[int] = mapped_column(BigInteger, nullable=True)
__table_args__ = (
Index("idx_traceroute_packet_id", "packet_id"),
Index("idx_traceroute_import_time_us", "import_time_us"),
)
Index("idx_traceroute_packet_id", "packet_id"),
Index("idx_traceroute_import_time_us", "import_time_us"),
)
class NodePublicKey(Base):
__tablename__ = "node_public_key"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
node_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
public_key: Mapped[str] = mapped_column(nullable=False)
first_seen_us: Mapped[int] = mapped_column(BigInteger, nullable=True)
last_seen_us: Mapped[int] = mapped_column(BigInteger, nullable=True)
__table_args__ = (
Index("idx_node_public_key_node_id", "node_id"),
Index("idx_node_public_key_public_key", "public_key"),
)

View File

@@ -1,6 +1,6 @@
from sqlalchemy import event
from sqlalchemy.engine.url import make_url
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy import event
from meshview import models

View File

@@ -86,6 +86,7 @@ if SECONDARY_KEYS:
else:
logger.info("Secondary keys: []")
# Thank you to "Robert Grizzell" for the decryption code!
# https://github.com/rgrizzell
def decrypt(packet, key):

View File

@@ -1,4 +1,3 @@
import datetime
import logging
import re
import time
@@ -12,7 +11,7 @@ from meshtastic.protobuf.config_pb2 import Config
from meshtastic.protobuf.mesh_pb2 import HardwareModel
from meshtastic.protobuf.portnums_pb2 import PortNum
from meshview import decode_payload, mqtt_database
from meshview.models import Node, Packet, PacketSeen, Traceroute
from meshview.models import Node, NodePublicKey, Packet, PacketSeen, Traceroute
logger = logging.getLogger(__name__)
@@ -97,10 +96,9 @@ async def process_envelope(topic, env):
"import_time_us": now_us,
"channel": env.channel_id,
}
utc_time = datetime.datetime.fromtimestamp(now_us / 1_000_000, datetime.UTC)
dialect = session.get_bind().dialect.name
stmt = None
if dialect == "sqlite":
stmt = (
sqlite_insert(Packet)
@@ -208,6 +206,28 @@ async def process_envelope(topic, env):
last_seen_us=now_us,
)
session.add(node)
if user.public_key:
public_key_hex = user.public_key.hex()
existing_key = (
await session.execute(
select(NodePublicKey).where(
NodePublicKey.node_id == node_id,
NodePublicKey.public_key == public_key_hex,
)
)
).scalar_one_or_none()
if existing_key:
existing_key.last_seen_us = now_us
else:
new_key = NodePublicKey(
node_id=node_id,
public_key=public_key_hex,
first_seen_us=now_us,
last_seen_us=now_us,
)
session.add(new_key)
except Exception as e:
print(f"Error processing NODEINFO_APP: {e}")

View File

@@ -1,5 +1,5 @@
import logging
from datetime import datetime, timedelta, timezone
from datetime import datetime, timedelta
from sqlalchemy import Text, and_, cast, func, or_, select
from sqlalchemy.orm import lazyload
@@ -179,7 +179,7 @@ async def get_mqtt_neighbors(since):
async def get_total_node_count(channel: str = None) -> int:
try:
async with database.async_session() as session:
now_us = int(datetime.now(timezone.utc).timestamp() * 1_000_000)
now_us = int(datetime.now(datetime.UTC).timestamp() * 1_000_000)
cutoff_us = now_us - 86400 * 1_000_000
q = select(func.count(Node.id)).where(Node.last_seen_us > cutoff_us)
@@ -196,7 +196,7 @@ async def get_total_node_count(channel: str = None) -> int:
async def get_top_traffic_nodes():
try:
async with database.async_session() as session:
now_us = int(datetime.now(timezone.utc).timestamp() * 1_000_000)
now_us = int(datetime.now(datetime.UTC).timestamp() * 1_000_000)
cutoff_us = now_us - 86400 * 1_000_000
total_packets_sent = func.count(func.distinct(Packet.id)).label("total_packets_sent")
total_times_seen = func.count(PacketSeen.packet_id).label("total_times_seen")
@@ -244,7 +244,7 @@ async def get_top_traffic_nodes():
async def get_node_traffic(node_id: int):
try:
async with database.async_session() as session:
now_us = int(datetime.now(timezone.utc).timestamp() * 1_000_000)
now_us = int(datetime.now(datetime.UTC).timestamp() * 1_000_000)
cutoff_us = now_us - 86400 * 1_000_000
packet_count = func.count().label("packet_count")
@@ -309,7 +309,7 @@ async def get_nodes(node_id=None, role=None, channel=None, hw_model=None, days_a
query = query.where(Node.hw_model == hw_model)
if days_active is not None:
now_us = int(datetime.now(timezone.utc).timestamp() * 1_000_000)
now_us = int(datetime.now(datetime.UTC).timestamp() * 1_000_000)
cutoff_us = now_us - int(timedelta(days_active).total_seconds() * 1_000_000)
query = query.where(Node.last_seen_us > cutoff_us)
@@ -337,7 +337,7 @@ async def get_packet_stats(
to_node: int | None = None,
from_node: int | None = None,
):
now = datetime.now(timezone.utc)
now = datetime.now(datetime.UTC)
if period_type == "hour":
start_time = now - timedelta(hours=length)

View File

@@ -131,6 +131,195 @@
color: #9fd4ff;
}
.inline-link:hover { color: #c7e6ff; }
/* --- QR Code & Import --- */
.node-actions {
display: flex;
gap: 10px;
margin-bottom: 14px;
flex-wrap: wrap;
}
.node-actions {
display: flex;
gap: 10px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.node-actions button {
background: linear-gradient(135deg, #2d3748 0%, #1a202c 100%);
border: 1px solid #4a5568;
border-radius: 8px;
color: #e4e9ee;
padding: 8px 16px;
font-size: 0.9rem;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
font-weight: 500;
transition: all 0.2s;
}
.node-actions button:hover {
background: linear-gradient(135deg, #3d4758 0%, #2a303c 100%);
border-color: #6a7788;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
}
.node-actions button.copied {
background: linear-gradient(135deg, #276749 0%, #22543d 100%);
border-color: #48bb78;
color: #fff;
}
.copy-success {
color: #4ade80 !important;
transition: opacity 0.3s;
}
/* --- QR Modal --- */
#qrModal {
display:none;
position:fixed;
top:0; left:0; width:100%; height:100%;
background:rgba(0,0,0,0.95);
z-index:10000;
align-items:center;
justify-content:center;
backdrop-filter:blur(4px);
}
#qrModal > div {
background:linear-gradient(145deg, #1e2228, #16191d);
border:1px solid #3a4450;
border-radius:16px;
padding:28px;
max-width:380px;
text-align:center;
color:#e4e9ee;
box-shadow:0 25px 80px rgba(0,0,0,0.6);
}
#qrModal .qr-header {
display:flex;
justify-content:space-between;
align-items:center;
margin-bottom:16px;
}
#qrModal .qr-title {
font-size:1.3rem;
font-weight:600;
margin:0;
color:#9fd4ff;
}
#qrModal .qr-close {
background:rgba(255,255,255,0.05);
border:1px solid #4a5568;
color:#9ca3af;
width:32px;
height:32px;
border-radius:8px;
cursor:pointer;
font-size:1.2rem;
display:flex;
align-items:center;
justify-content:center;
transition:all 0.2s;
}
#qrModal .qr-close:hover {
background:rgba(255,255,255,0.1);
color:#fff;
border-color:#6a7788;
}
#qrModal .qr-node-name {
font-size:1.15rem;
color:#fff;
margin:12px 0 20px;
font-weight:500;
}
#qrModal .qr-image {
background:#fff;
padding:16px;
border-radius:12px;
display:inline-block;
margin-bottom:16px;
box-shadow:0 8px 30px rgba(0,0,0,0.4);
}
#qrModal .qr-image img {
display:block;
border-radius:4px;
}
#qrModal .qr-url-container {
background:rgba(0,0,0,0.4);
border-radius:8px;
padding:12px;
margin-bottom:18px;
}
#qrModal .qr-url {
font-size:0.65rem;
color:#9ca3af;
word-break:break-all;
font-family:'Monaco', 'Menlo', monospace;
line-height:1.4;
max-height:48px;
overflow-y:auto;
display:block;
}
#qrModal .qr-actions {
display:flex;
gap:12px;
justify-content:center;
}
#qrModal .qr-btn {
background:linear-gradient(135deg, #2d3748 0%, #1a202c 100%);
border:1px solid #4a5568;
color:#e4e9ee;
padding:12px 24px;
border-radius:10px;
cursor:pointer;
font-size:0.9rem;
font-weight:500;
transition:all 0.2s;
display:flex;
align-items:center;
gap:8px;
min-width:140px;
justify-content:center;
}
#qrModal .qr-btn:hover {
background:linear-gradient(135deg, #3d4758 0%, #2a303c 100%);
border-color:#6a7788;
transform:translateY(-2px);
box-shadow:0 4px 12px rgba(0,0,0,0.3);
}
#qrModal .qr-btn.copied {
background:linear-gradient(135deg, #276749 0%, #22543d 100%);
border-color:#48bb78;
color:#fff;
}
/* --- Impersonation Warning --- */
.impersonation-warning {
background: rgba(239, 68, 68, 0.15);
border: 1px solid rgba(239, 68, 68, 0.4);
border-radius: 8px;
padding: 12px 16px;
margin-bottom: 14px;
display: flex;
align-items: flex-start;
gap: 10px;
}
.impersonation-warning .warning-icon {
font-size: 1.2rem;
}
.impersonation-warning .warning-content {
flex: 1;
}
.impersonation-warning .warning-title {
color: #f87171;
font-weight: bold;
margin-bottom: 4px;
}
.impersonation-warning .warning-text {
font-size: 0.85rem;
color: #ccc;
}
{% endblock %}
{% block body %}
@@ -141,6 +330,25 @@
<span id="nodeLabel"></span>
</h5>
<!-- Node Actions -->
<div class="node-actions" id="nodeActions" style="display:none;">
<button onclick="copyImportUrl()" id="copyUrlBtn">
<span>📋</span> <span data-translate-lang="copy_import_url">Copy Import URL</span>
</button>
<button onclick="showQrCode()" id="showQrBtn">
<span>🔳</span> <span data-translate-lang="show_qr_code">Show QR Code</span>
</button>
</div>
<!-- Impersonation Warning -->
<div id="impersonationWarning" class="impersonation-warning" style="display:none;">
<span class="warning-icon">⚠️</span>
<div class="warning-content">
<div class="warning-title" data-translate-lang="potential_impersonation">Potential Impersonation Detected</div>
<div class="warning-text" id="impersonationText"></div>
</div>
</div>
<!-- Node Info -->
<div id="node-info" class="node-info">
<div><strong data-translate-lang="node_id">Node ID</strong><strong>: </strong><span id="info-node-id"></span></div>
@@ -284,7 +492,30 @@
</div>
</div>
<!-- QR Code Modal -->
<div id="qrModal">
<div>
<div class="qr-header">
<h3 class="qr-title" data-translate-lang="share_contact_qr">Share Contact QR</h3>
<button class="qr-close" onclick="closeQrModal()"></button>
</div>
<div class="qr-node-name" id="qrNodeName">Loading...</div>
<div class="qr-image">
<div id="qrCodeContainer"></div>
</div>
<div class="qr-url-container">
<span class="qr-url" id="qrUrl">Generating...</span>
</div>
<div class="qr-actions">
<button class="qr-btn" onclick="copyQrUrl()" id="copyQrBtn">
<span>📋</span> <span data-translate-lang="copy_url">Copy URL</span>
</button>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
<script src="/static/portmaps.js"></script>
<script>
@@ -1309,6 +1540,9 @@ document.addEventListener("DOMContentLoaded", async () => {
requestAnimationFrame(async () => {
await loadNodeInfo();
// Load QR code URL and impersonation check
await loadNodeQrAndImpersonation();
// ✅ MAP MUST EXIST FIRST
if (!map) initMap();
@@ -1430,12 +1664,126 @@ async function loadNodeStats(nodeId) {
const csv = rows.map(r => r.join(",")).join("\n");
const blob = new Blob([csv], { type: "text/csv" });
const link = document.createElement("a");
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = `packets_${fromNodeId}_${Date.now()}.csv`;
link.click();
}
/* ======================================================
QR CODE & IMPORT URL
====================================================== */
let currentMeshtasticUrl = "";
async function loadNodeQrAndImpersonation() {
const actionsDiv = document.getElementById("nodeActions");
const warningDiv = document.getElementById("impersonationWarning");
try {
const [qrRes, impRes] = await Promise.all([
fetch(`/api/node/${fromNodeId}/qr`),
fetch(`/api/node/${fromNodeId}/impersonation-check`)
]);
const qrData = await qrRes.json();
if (qrRes.ok && qrData.meshtastic_url) {
currentMeshtasticUrl = qrData.meshtastic_url;
actionsDiv.style.display = "flex";
} else {
actionsDiv.style.display = "none";
}
const impData = await impRes.json();
if (impRes.ok && impData.potential_impersonation) {
warningDiv.style.display = "flex";
document.getElementById("impersonationText").textContent =
impData.warning || `This node has sent ${impData.unique_public_key_count} different public keys. This could indicate impersonation.`;
} else {
warningDiv.style.display = "none";
}
} catch (err) {
console.error("Failed to load QR/impersonation data:", err);
actionsDiv.style.display = "none";
warningDiv.style.display = "none";
}
}
function copyImportUrl() {
if (!currentMeshtasticUrl) return;
navigator.clipboard.writeText(currentMeshtasticUrl).then(() => {
const btn = document.getElementById("copyUrlBtn");
const originalText = btn.innerHTML;
btn.innerHTML = '<span>✅</span> <span data-translate-lang="copied">Copied!</span>';
btn.classList.add("copy-success");
setTimeout(() => {
btn.innerHTML = originalText;
btn.classList.remove("copy-success");
}, 2000);
}).catch(err => {
console.error("Failed to copy:", err);
alert("Failed to copy URL to clipboard");
});
}
function showQrCode() {
if (!currentMeshtasticUrl) return;
const node = currentNode;
document.getElementById("qrNodeName").textContent =
node && node.long_name ? node.long_name : `Node ${fromNodeId}`;
document.getElementById("qrUrl").textContent = currentMeshtasticUrl;
generateQrCode(currentMeshtasticUrl);
document.getElementById("qrModal").style.display = "flex";
}
function closeQrModal() {
document.getElementById("qrModal").style.display = "none";
}
function copyQrUrl() {
navigator.clipboard.writeText(currentMeshtasticUrl).then(() => {
const btn = document.getElementById("copyQrBtn");
const originalHTML = btn.innerHTML;
btn.innerHTML = '<span>✅</span> <span data-translate-lang="copied">Copied!</span>';
btn.classList.add("copied");
setTimeout(() => {
btn.innerHTML = originalHTML;
btn.classList.remove("copied");
}, 2000);
}).catch(err => {
console.error("Failed to copy:", err);
});
}
function generateQrCode(text) {
const container = document.getElementById("qrCodeContainer");
if (!container) return;
container.innerHTML = "";
try {
new QRCode(container, {
text: text,
width: 200,
height: 200,
colorDark: "#000000",
colorLight: "#ffffff",
correctLevel: QRCode.CorrectLevel.M
});
} catch (e) {
console.error("QR Code generation error:", e);
container.innerHTML = '<div style="padding:20px;color:#f87171;">Failed to generate QR code</div>';
}
}
/* ======================================================
END QR CODE & IMPORT URL
====================================================== */
</script>

View File

@@ -12,7 +12,7 @@ from meshtastic.protobuf.portnums_pb2 import PortNum
from meshview import database, decode_payload, store
from meshview.__version__ import __version__, _git_revision_short, get_version_info
from meshview.config import CONFIG
from meshview.models import Node
from meshview.models import Node, NodePublicKey
from meshview.models import Packet as PacketModel
from meshview.models import PacketSeen as PacketSeenModel
@@ -923,3 +923,106 @@ async def api_stats_top(request):
"nodes": nodes,
}
)
@routes.get("/api/node/{node_id}/qr")
async def api_node_qr(request):
"""
Generate a Meshtastic URL for importing the node as a contact.
Returns the URL that can be used to generate a QR code.
"""
try:
node_id_str = request.match_info["node_id"]
node_id = int(node_id_str, 0)
except (KeyError, ValueError):
return web.json_response({"error": "Invalid node_id"}, status=400)
node = await store.get_node(node_id)
if not node:
return web.json_response({"error": "Node not found"}, status=404)
try:
from meshtastic.protobuf.admin_pb2 import SharedContact
from meshtastic.protobuf.mesh_pb2 import User
user = User()
user.id = f"!{node_id:08x}"
if node.long_name:
user.long_name = node.long_name
if node.short_name:
user.short_name = node.short_name
if node.hw_model:
try:
from meshtastic.protobuf.mesh_pb2 import HardwareModel
hw_model_value = getattr(HardwareModel, node.hw_model.upper(), None)
if hw_model_value is not None:
user.hw_model = hw_model_value
except (AttributeError, TypeError):
pass
contact = SharedContact()
contact.node_num = node_id
contact.user.CopyFrom(user)
contact.manually_verified = False
contact_bytes = contact.SerializeToString()
import base64
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(
{
"node_id": node_id,
"long_name": node.long_name,
"short_name": node.short_name,
"meshtastic_url": meshtastic_url,
}
)
except Exception as e:
import traceback
logger.error(f"Error generating QR URL for node {node_id}: {e}")
logger.error(traceback.format_exc())
return web.json_response({"error": f"Failed to generate URL: {str(e)}"}, status=500)
@routes.get("/api/node/{node_id}/impersonation-check")
async def api_node_impersonation_check(request):
"""
Check if a node has multiple different public keys, which could indicate impersonation.
"""
try:
node_id_str = request.match_info["node_id"]
node_id = int(node_id_str, 0)
except (KeyError, ValueError):
return web.json_response({"error": "Invalid node_id"}, status=400)
try:
async with database.async_session() as session:
result = await session.execute(
select(NodePublicKey.public_key).where(NodePublicKey.node_id == node_id).distinct()
)
public_keys = result.scalars().all()
unique_key_count = len(public_keys)
return web.json_response(
{
"node_id": node_id,
"unique_public_key_count": unique_key_count,
"potential_impersonation": unique_key_count > 1,
"public_keys": public_keys
if unique_key_count <= 3
else public_keys[:3] + ["..."],
"warning": "Multiple different public keys detected. This node may be getting impersonated."
if unique_key_count > 1
else None,
}
)
except Exception as e:
logger.error(f"Error checking impersonation for node {node_id}: {e}")
return web.json_response({"error": "Failed to check impersonation"}, status=500)