mirror of
https://github.com/pablorevilla-meshtastic/meshview.git
synced 2026-03-04 23:27:46 +01:00
Compare commits
22 Commits
db_updates
...
v3.0.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52f1a1e788 | ||
|
|
f44a78730a | ||
|
|
a9a5e046ea | ||
|
|
37386f9e28 | ||
|
|
b66bfb1ee9 | ||
|
|
caf9cd1596 | ||
|
|
a4ebd2b23c | ||
|
|
5676ade6b7 | ||
|
|
319f8eac06 | ||
|
|
d85132133a | ||
|
|
b6d8af409c | ||
|
|
896a0980d5 | ||
|
|
7d395e5e27 | ||
|
|
c3cc01d7e7 | ||
|
|
ecbadc6087 | ||
|
|
ff30623bdf | ||
|
|
a43433ccb4 | ||
|
|
4d9db2a52c | ||
|
|
e30b59851f | ||
|
|
36dd91be63 | ||
|
|
4516c84128 | ||
|
|
a882bc22dd |
6
.github/workflows/container.yml
vendored
6
.github/workflows/container.yml
vendored
@@ -17,13 +17,15 @@ jobs:
|
||||
# list of Docker images to use as base name for tags
|
||||
images: |
|
||||
ghcr.io/${{ github.repository }}
|
||||
# latest tag is only set for semver/tag-based builds (default behavior)
|
||||
flavor: |
|
||||
latest=auto
|
||||
# generate Docker tags based on the following events/attributes
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=match,pattern=v\d.\d.\d,value=latest
|
||||
- name: Login to GitHub Container Registry
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
@@ -49,4 +51,4 @@ jobs:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
# optional cache (speeds up rebuilds)
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
@@ -35,7 +35,7 @@ RUN uv pip install --no-cache-dir --upgrade pip \
|
||||
COPY --chown=${APP_USER}:${APP_USER} . .
|
||||
|
||||
# Patch config
|
||||
RUN patch sample.config.ini < container/config.patch
|
||||
RUN patch -p1 < container/config.patch
|
||||
|
||||
# Clean
|
||||
RUN rm -rf /app/.git* && \
|
||||
@@ -77,4 +77,3 @@ CMD ["--pid_dir", "/tmp", "--py_exec", "/opt/venv/bin/python", "--config", "/etc
|
||||
|
||||
EXPOSE 8081
|
||||
VOLUME [ "/etc/meshview", "/var/lib/meshview", "/var/log/meshview" ]
|
||||
|
||||
|
||||
@@ -132,7 +132,7 @@ password =
|
||||
# Examples:
|
||||
# sqlite+aiosqlite:///var/lib/meshview/packets.db
|
||||
# postgresql+asyncpg://user:pass@host:5432/meshview
|
||||
connection_string = sqlite+aiosqlite:///var/lib/meshview/packets.db
|
||||
connection_string = sqlite+aiosqlite:////var/lib/meshview/packets.db
|
||||
```
|
||||
|
||||
### Database Backups
|
||||
|
||||
112
README.md
112
README.md
@@ -4,6 +4,13 @@
|
||||
|
||||
The project serves as a real-time monitoring and diagnostic tool for the Meshtastic mesh network. It provides detailed insights into network activity, including message traffic, node positions, and telemetry data.
|
||||
|
||||
### Version 3.0.3 — January 2026
|
||||
- Added database support for MySQL (not tested, would love to have someone test!) and PostgreSQL (alongside SQLite) for larger or shared deployments.
|
||||
- Configuration updated to allow selecting the database backend via connection string.
|
||||
|
||||
### Version 3.0.2 — January 2026
|
||||
- Changes to the Database to will make it so that there is a need for space when updating to the latest. SQlite requires to rebuild the database when droping a column. ( we are droping some of the old columns) so make sure you have 1.2x the size of the db of space in your environment. Depending on how big your db is it would take a long time.
|
||||
|
||||
### Version 3.0.1 — December 2025
|
||||
|
||||
#### 🌐 Multi-Language Support (i18n)
|
||||
@@ -82,23 +89,27 @@ Samples of currently running instances:
|
||||
|
||||
- https://meshview.bayme.sh (SF Bay Area)
|
||||
- https://www.svme.sh (Sacramento Valley)
|
||||
- https://meshview.nyme.sh (New York)
|
||||
- https://meshview.nyme.sh (New York)
|
||||
- https://meshview.socalmesh.org (LA Area)
|
||||
- https://map.wpamesh.net (Western Pennsylvania)
|
||||
- https://meshview.chicagolandmesh.org (Chicago)
|
||||
- https://meshview.mt.gt (Canadaverse)
|
||||
- https://canadaverse.org (Canadaverse)
|
||||
- https://meshview.mt.gt (Canada)
|
||||
- https://canadaverse.org (Canada)
|
||||
- https://meshview.meshtastic.es (Spain)
|
||||
- https://view.mtnme.sh (North Georgia / East Tennessee)
|
||||
- https://meshview.lsinfra.de (Hessen - Germany)
|
||||
- https://meshview.pvmesh.org (Pioneer Valley, Massachusetts)
|
||||
- https://meshview.louisianamesh.org (Louisiana)
|
||||
- https://www.swlamesh.com/map (Southwest Louisiana)
|
||||
- https://meshview.meshcolombia.co/ (Colombia)
|
||||
- https://meshview-salzburg.jmt.gr/ (Salzburg / Austria)
|
||||
- https://www.swlamesh.com (Southwest Louisiana)
|
||||
- https://meshview.meshcolombia.co (Colombia)
|
||||
- https://meshview-salzburg.jmt.gr (Salzburg / Austria)
|
||||
- https://map.cromesh.eu (Coatia)
|
||||
- https://view.meshdresden.eu (Dresden / Germany)
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
### Updating from 2.x to 3.x
|
||||
We are adding the use of Alembic. If using GitHub
|
||||
Update your codebase by running the pull command
|
||||
@@ -278,18 +289,6 @@ password = large4cats
|
||||
# postgresql+asyncpg://user:pass@host:5432/meshview
|
||||
connection_string = sqlite+aiosqlite:///packets.db
|
||||
|
||||
> **NOTE (PostgreSQL setup)**
|
||||
> If you want to use PostgreSQL instead of SQLite:
|
||||
>
|
||||
> 1) Install PostgreSQL for your OS.
|
||||
> 2) Create a user and database:
|
||||
> - `CREATE USER meshview WITH PASSWORD 'change_me';`
|
||||
> - `CREATE DATABASE meshview OWNER meshview;`
|
||||
> 3) Update `config.ini`:
|
||||
> - `connection_string = postgresql+asyncpg://meshview:change_me@localhost:5432/meshview`
|
||||
> 4) Initialize the schema:
|
||||
> - `./env/bin/python startdb.py`
|
||||
|
||||
|
||||
# -------------------------
|
||||
# Database Cleanup Configuration
|
||||
@@ -321,6 +320,20 @@ db_cleanup_logfile = dbcleanup.log
|
||||
|
||||
---
|
||||
|
||||
## NOTE (PostgreSQL setup)**
|
||||
If you want to use PostgreSQL instead of SQLite:
|
||||
|
||||
Install PostgreSQL for your OS.
|
||||
Create a user and database:
|
||||
```
|
||||
`CREATE USER meshview WITH PASSWORD 'change_me';`
|
||||
`CREATE DATABASE meshview OWNER meshview;`
|
||||
```
|
||||
Update `config.ini` example:
|
||||
```
|
||||
`connection_string = postgresql+asyncpg://meshview:change_me@localhost:5432/meshview`
|
||||
```
|
||||
|
||||
## Running Meshview
|
||||
|
||||
Start the database manager:
|
||||
@@ -490,16 +503,15 @@ db_cleanup_logfile = dbcleanup.log
|
||||
```
|
||||
Once changes are done you need to restart the script for changes to load.
|
||||
|
||||
### Alternatively we can do it via your OS
|
||||
### Alternatively we can do it via your OS (This example is Ubuntu like OS)
|
||||
- Create and save bash script below. (Modify /path/to/file/ to the correct path)
|
||||
- Name it cleanup.sh
|
||||
- Make it executable.
|
||||
```bash
|
||||
#!/bin/bash
|
||||
#!/bin/bash
|
||||
|
||||
DB_FILE="/path/to/file/packets.db"
|
||||
|
||||
|
||||
# Stop DB service
|
||||
sudo systemctl stop meshview-db.service
|
||||
sudo systemctl stop meshview-web.service
|
||||
@@ -533,6 +545,64 @@ sudo systemctl start meshview-web.service
|
||||
|
||||
echo "Database cleanup completed on $(date)"
|
||||
|
||||
```
|
||||
- If you are using PostgreSQL, use this version instead (adjust credentials/DB name):
|
||||
```bash
|
||||
#!/bin/bash
|
||||
|
||||
DB_NAME="meshview"
|
||||
DB_USER="meshview"
|
||||
DB_HOST="localhost"
|
||||
DB_PORT="5432"
|
||||
|
||||
# Stop DB service
|
||||
sudo systemctl stop meshview-db.service
|
||||
sudo systemctl stop meshview-web.service
|
||||
|
||||
sleep 5
|
||||
echo "Run cleanup..."
|
||||
# Run cleanup queries
|
||||
psql "postgresql://${DB_USER}@${DB_HOST}:${DB_PORT}/${DB_NAME}" <<'EOF'
|
||||
WITH deleted AS (
|
||||
DELETE FROM packet
|
||||
WHERE import_time_us IS NOT NULL
|
||||
AND import_time_us < (EXTRACT(EPOCH FROM (NOW() - INTERVAL '14 days')) * 1000000)
|
||||
RETURNING 1
|
||||
)
|
||||
SELECT 'packet deleted: ' || COUNT(*) FROM deleted;
|
||||
|
||||
WITH deleted AS (
|
||||
DELETE FROM packet_seen
|
||||
WHERE import_time_us IS NOT NULL
|
||||
AND import_time_us < (EXTRACT(EPOCH FROM (NOW() - INTERVAL '14 days')) * 1000000)
|
||||
RETURNING 1
|
||||
)
|
||||
SELECT 'packet_seen deleted: ' || COUNT(*) FROM deleted;
|
||||
|
||||
WITH deleted AS (
|
||||
DELETE FROM traceroute
|
||||
WHERE import_time_us IS NOT NULL
|
||||
AND import_time_us < (EXTRACT(EPOCH FROM (NOW() - INTERVAL '14 days')) * 1000000)
|
||||
RETURNING 1
|
||||
)
|
||||
SELECT 'traceroute deleted: ' || COUNT(*) FROM deleted;
|
||||
|
||||
WITH deleted AS (
|
||||
DELETE FROM node
|
||||
WHERE last_seen_us IS NULL
|
||||
OR last_seen_us < (EXTRACT(EPOCH FROM (NOW() - INTERVAL '14 days')) * 1000000)
|
||||
RETURNING 1
|
||||
)
|
||||
SELECT 'node deleted: ' || COUNT(*) FROM deleted;
|
||||
|
||||
VACUUM;
|
||||
EOF
|
||||
|
||||
# Start DB service
|
||||
sudo systemctl start meshview-db.service
|
||||
sudo systemctl start meshview-web.service
|
||||
|
||||
echo "Database cleanup completed on $(date)"
|
||||
```
|
||||
- Schedule running the script on a regular basis.
|
||||
- In this example it runs every night at 2:00am.
|
||||
|
||||
@@ -200,7 +200,7 @@ Response Example
|
||||
|
||||
### GET `/api/edges`
|
||||
Returns network edges (connections between nodes) based on traceroutes and neighbor info.
|
||||
Traceroute edges are collected over the last 48 hours. Neighbor edges are based on
|
||||
Traceroute edges are collected over the last 12 hours. Neighbor edges are based on
|
||||
port 71 packets.
|
||||
|
||||
Query Parameters
|
||||
@@ -366,7 +366,7 @@ Response Example
|
||||
{
|
||||
"status": "healthy",
|
||||
"timestamp": "2025-07-22T12:45:00+00:00",
|
||||
"version": "3.0.0",
|
||||
"version": "3.0.3",
|
||||
"git_revision": "abc1234",
|
||||
"database": "connected",
|
||||
"database_size": "12.34 MB",
|
||||
@@ -384,8 +384,9 @@ Returns version metadata.
|
||||
Response Example
|
||||
```json
|
||||
{
|
||||
"version": "3.0.0",
|
||||
"version": "3.0.3",
|
||||
"release_date": "2026-1-15",
|
||||
"git_revision": "abc1234",
|
||||
"build_time": "2025-11-01T12:00:00+00:00"
|
||||
"git_revision_short": "abc1234"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
__version__ = "3.0.2"
|
||||
__release_date__ = "2026-1-9"
|
||||
__version__ = "3.0.4"
|
||||
__release_date__ = "2026-1-24"
|
||||
|
||||
|
||||
def get_git_revision():
|
||||
|
||||
@@ -99,4 +99,8 @@ class Traceroute(Base):
|
||||
route_return: Mapped[bytes] = mapped_column(nullable=True)
|
||||
import_time_us: Mapped[int] = mapped_column(BigInteger, nullable=True)
|
||||
|
||||
__table_args__ = (Index("idx_traceroute_import_time_us", "import_time_us"),)
|
||||
__table_args__ = (
|
||||
Index("idx_traceroute_packet_id", "packet_id"),
|
||||
Index("idx_traceroute_import_time_us", "import_time_us"),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,16 +1,32 @@
|
||||
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
|
||||
|
||||
|
||||
def init_database(database_connection_string):
|
||||
global engine, async_session
|
||||
|
||||
url = make_url(database_connection_string)
|
||||
kwargs = {"echo": False}
|
||||
|
||||
if url.drivername.startswith("sqlite"):
|
||||
kwargs["connect_args"] = {"timeout": 900}
|
||||
kwargs["connect_args"] = {"timeout": 900} # seconds
|
||||
|
||||
engine = create_async_engine(url, **kwargs)
|
||||
|
||||
# Enforce SQLite pragmas on every new DB connection
|
||||
if url.drivername.startswith("sqlite"):
|
||||
|
||||
@event.listens_for(engine.sync_engine, "connect")
|
||||
def _set_sqlite_pragmas(dbapi_conn, _):
|
||||
cursor = dbapi_conn.cursor()
|
||||
cursor.execute("PRAGMA journal_mode=WAL;")
|
||||
cursor.execute("PRAGMA busy_timeout=900000;") # ms
|
||||
cursor.execute("PRAGMA synchronous=NORMAL;")
|
||||
cursor.close()
|
||||
|
||||
async_session = async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
|
||||
|
||||
@@ -9,8 +9,9 @@ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
from google.protobuf.message import DecodeError
|
||||
|
||||
from meshtastic.protobuf.mqtt_pb2 import ServiceEnvelope
|
||||
from meshview.config import CONFIG
|
||||
|
||||
KEY = base64.b64decode("1PG7OiApB1nwvP+rz05pAQ==")
|
||||
PRIMARY_KEY = base64.b64decode("1PG7OiApB1nwvP+rz05pAQ==")
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
@@ -21,20 +22,79 @@ logging.basicConfig(
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def decrypt(packet):
|
||||
if packet.HasField("decoded"):
|
||||
return
|
||||
def _parse_skip_node_ids():
|
||||
mqtt_config = CONFIG.get("mqtt", {})
|
||||
raw_value = mqtt_config.get("skip_node_ids", "")
|
||||
if not raw_value:
|
||||
return set()
|
||||
|
||||
if isinstance(raw_value, str):
|
||||
raw_value = raw_value.strip()
|
||||
if not raw_value:
|
||||
return set()
|
||||
values = [v.strip() for v in raw_value.split(",") if v.strip()]
|
||||
else:
|
||||
values = [raw_value]
|
||||
|
||||
skip_ids = set()
|
||||
for value in values:
|
||||
try:
|
||||
skip_ids.add(int(value, 0))
|
||||
except (TypeError, ValueError):
|
||||
logger.warning("Invalid node id in mqtt.skip_node_ids: %s", value)
|
||||
return skip_ids
|
||||
|
||||
|
||||
def _parse_secondary_keys():
|
||||
mqtt_config = CONFIG.get("mqtt", {})
|
||||
raw_value = mqtt_config.get("secondary_keys", "")
|
||||
if not raw_value:
|
||||
return []
|
||||
|
||||
if isinstance(raw_value, str):
|
||||
raw_value = raw_value.strip()
|
||||
if not raw_value:
|
||||
return []
|
||||
values = [v.strip() for v in raw_value.split(",") if v.strip()]
|
||||
else:
|
||||
values = [raw_value]
|
||||
|
||||
keys = []
|
||||
for value in values:
|
||||
try:
|
||||
keys.append(base64.b64decode(value))
|
||||
except (TypeError, ValueError):
|
||||
logger.warning("Invalid base64 key in mqtt.secondary_keys: %s", value)
|
||||
return keys
|
||||
|
||||
|
||||
SKIP_NODE_IDS = _parse_skip_node_ids()
|
||||
SECONDARY_KEYS = _parse_secondary_keys()
|
||||
|
||||
|
||||
def _try_decrypt(packet, key):
|
||||
packet_id = packet.id.to_bytes(8, "little")
|
||||
from_node_id = getattr(packet, "from").to_bytes(8, "little")
|
||||
nonce = packet_id + from_node_id
|
||||
|
||||
cipher = Cipher(algorithms.AES(KEY), modes.CTR(nonce))
|
||||
cipher = Cipher(algorithms.AES(key), modes.CTR(nonce))
|
||||
decryptor = cipher.decryptor()
|
||||
raw_proto = decryptor.update(packet.encrypted) + decryptor.finalize()
|
||||
try:
|
||||
packet.decoded.ParseFromString(raw_proto)
|
||||
except DecodeError:
|
||||
pass
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def decrypt(packet):
|
||||
if packet.HasField("decoded"):
|
||||
return
|
||||
if _try_decrypt(packet, PRIMARY_KEY):
|
||||
return
|
||||
for key in SECONDARY_KEYS:
|
||||
if _try_decrypt(packet, key):
|
||||
return
|
||||
|
||||
|
||||
async def get_topic_envelopes(mqtt_server, mqtt_port, topics, mqtt_user, mqtt_passwd):
|
||||
@@ -70,9 +130,8 @@ async def get_topic_envelopes(mqtt_server, mqtt_port, topics, mqtt_user, mqtt_pa
|
||||
if not envelope.packet.decoded:
|
||||
continue
|
||||
|
||||
# Skip packets from specific node
|
||||
# FIXME: make this configurable as a list of node IDs to skip
|
||||
if getattr(envelope.packet, "from", None) == 2144342101:
|
||||
# Skip packets from configured node IDs
|
||||
if getattr(envelope.packet, "from", None) in SKIP_NODE_IDS:
|
||||
continue
|
||||
|
||||
msg_count += 1
|
||||
|
||||
@@ -178,7 +178,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
const sinceUs = Math.floor(sixDaysAgoMs * 1000);
|
||||
|
||||
const url =
|
||||
`/api/packets?portnum=1&contains=${encodeURIComponent(tag)}&since=${sinceUs}`;
|
||||
`/api/packets?portnum=1&contains=${encodeURIComponent(tag)}&since=${sinceUs}&limit=1000`;
|
||||
|
||||
const resp = await fetch(url);
|
||||
const data = await resp.json();
|
||||
|
||||
@@ -203,6 +203,12 @@ async def index(request):
|
||||
raise web.HTTPFound(location=starting_url)
|
||||
|
||||
|
||||
# redirect for backwards compatibility
|
||||
@routes.get("/packet_list/{packet_id}")
|
||||
async def redirect_packet_list(request):
|
||||
packet_id = request.match_info["packet_id"]
|
||||
raise web.HTTPFound(location=f"/node/{packet_id}")
|
||||
|
||||
# Generic static HTML route
|
||||
@routes.get("/{page}")
|
||||
async def serve_page(request):
|
||||
|
||||
@@ -180,13 +180,17 @@ async def api_packets(request):
|
||||
logger.warning(f"Invalid node_id: {node_id_str}")
|
||||
|
||||
# --- Fetch packets using explicit filters ---
|
||||
contains_for_query = contains
|
||||
if portnum == PortNum.TEXT_MESSAGE_APP and contains:
|
||||
contains_for_query = None
|
||||
|
||||
packets = await store.get_packets(
|
||||
from_node_id=from_node_id,
|
||||
to_node_id=to_node_id,
|
||||
node_id=node_id,
|
||||
portnum=portnum,
|
||||
after=since,
|
||||
contains=contains,
|
||||
contains=contains_for_query,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
@@ -414,7 +418,7 @@ async def api_stats_count(request):
|
||||
|
||||
@routes.get("/api/edges")
|
||||
async def api_edges(request):
|
||||
since = datetime.datetime.now() - datetime.timedelta(hours=48)
|
||||
since = datetime.datetime.now() - datetime.timedelta(hours=12)
|
||||
filter_type = request.query.get("type")
|
||||
|
||||
# NEW → optional single-node filter
|
||||
|
||||
@@ -76,6 +76,12 @@ port = 1883
|
||||
username = meshdev
|
||||
password = large4cats
|
||||
|
||||
# Optional list of node IDs to ignore. Comma-separated.
|
||||
skip_node_ids =
|
||||
|
||||
# Optional list of secondary AES keys (base64), comma-separated.
|
||||
secondary_keys =
|
||||
|
||||
|
||||
# -------------------------
|
||||
# Database Configuration
|
||||
|
||||
Reference in New Issue
Block a user