mirror of
https://github.com/pablorevilla-meshtastic/meshview.git
synced 2026-03-04 23:27:46 +01:00
Merge PR 124: QR/import, impersonation detection, node public keys
This commit is contained in:
43
alembic/versions/a0c9c13e118f_add_node_public_key.py
Normal file
43
alembic/versions/a0c9c13e118f_add_node_public_key.py
Normal 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")
|
||||
@@ -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...",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user