Files
rich 01c49d7bdd Add firmware version tracking and on-device history backfill
Repeaters now report their firmware version (REQ_TYPE_GET_OWNER_INFO),
checked daily and after a reboot, into meshcore_repeater_info. Firmware
changes show as Grafana annotations and in the web UI node list.

Repeaters on keymind/Cascade firmware keep 7 days of half-hourly battery
voltage and MCU temperature. With history = true and an admin login, the
collector fetches enough days via the remote CLI to fill gaps after
missed polls, writing them to meshcore_repeater_history.
2026-10-13 22:35:12 -04:00

388 lines
17 KiB
Python

"""
Web UI for meshcore_influx: status, node (repeater/companion) management, companion radio and
InfluxDB settings. Runs inside the collector's event loop (aiohttp).
Security model: one admin password (scrypt hash in [web] password_hash),
in-memory session tokens in an HttpOnly SameSite=Strict cookie, and all
mutating requests must be JSON (blocks cross-site form posts). Secrets are
never sent to the browser; they come back as SECRET and are left unchanged.
"""
import asyncio
import copy
import glob
import logging
import os
import secrets
import time
from pathlib import Path
from aiohttp import web
import meshcore_influx as mi
log = logging.getLogger("meshcore_influx.web")
STATIC = Path(__file__).with_name("static")
SECRET = "__unchanged__"
SESSION_TTL = 7 * 86400
COOKIE = "mcsession"
CONNECTION_KEYS = {"type": str, "port": str, "baudrate": int, "host": str, "address": str, "pin": str}
INFLUX_KEYS = {"url": str, "token": str, "org": str, "bucket": str}
POLL_KEYS = {"interval_seconds": int, "retries": int, "timeout_seconds": float,
"low_battery_mv": int, "recover_battery_mv": int,
"low_battery_interval_seconds": int, "low_battery_skip": list,
"local_stats_interval_seconds": int, "location_interval_seconds": int}
REPEATER_KEYS = {"name": str, "password": str, "pubkey_prefix": str, "enabled": bool,
"status": bool, "telemetry": bool, "neighbours": bool, "history": bool,
"interval_seconds": int, "low_battery_mv": int, "recover_battery_mv": int,
"low_battery_interval_seconds": int, "low_battery_skip": list}
COMPANION_KEYS = {"name": str, "pubkey_prefix": str, "enabled": bool, "location": bool,
"interval_seconds": int, "low_battery_mv": int, "recover_battery_mv": int,
"low_battery_interval_seconds": int}
NODE_SCHEMAS = {"repeater": REPEATER_KEYS, "companion": COMPANION_KEYS}
ADVERT_KEYS = {"interval_hours": int, "flood": bool}
SECRET_KEYS = {"token", "password", "pin"}
class Api:
def __init__(self, col: "mi.Collector", logbuf: "mi.LogBuffer"):
self.col = col
self.logbuf = logbuf
self.sessions: dict[str, float] = {}
self.fail_delay = 0.0
# ------------------------------------------------------------ helpers
def _authed(self, request: web.Request) -> bool:
tok = request.cookies.get(COOKIE)
exp = self.sessions.get(tok or "")
if exp and exp > time.time():
return True
self.sessions.pop(tok or "", None)
return False
@staticmethod
async def _json(request: web.Request) -> dict:
if request.content_type != "application/json":
raise web.HTTPUnsupportedMediaType(text="JSON required")
try:
body = await request.json()
except ValueError:
raise web.HTTPBadRequest(text="invalid JSON") from None
if not isinstance(body, dict):
raise web.HTTPBadRequest(text="expected a JSON object")
return body
@staticmethod
def _clean(body: dict, schema: dict, old: dict) -> dict:
"""Coerce types, drop blanks (= inherit/default), keep secrets that came back masked."""
out = {}
for k, typ in schema.items():
if k not in body:
if k in old:
out[k] = old[k]
continue
v = body[k]
if k in SECRET_KEYS and v == SECRET:
if k in old:
out[k] = old[k]
continue
if v is None or v == "":
continue
try:
if typ is bool:
out[k] = bool(v)
elif typ is list:
if not isinstance(v, list):
raise ValueError
out[k] = [str(x) for x in v]
else:
out[k] = typ(v)
if typ is str:
out[k] = out[k].strip() if k not in SECRET_KEYS else out[k]
except (TypeError, ValueError):
raise web.HTTPBadRequest(text=f"{k}: invalid value {v!r}") from None
return out
@staticmethod
def _mask(d: dict) -> dict:
return {k: (SECRET if k in SECRET_KEYS and v else v) for k, v in d.items()}
async def _apply(self, raw: dict, renamed=None) -> web.Response:
try:
await self.col.update_config(raw, renamed)
except ValueError as e:
raise web.HTTPBadRequest(text=str(e)) from None
except OSError as e:
raise web.HTTPInternalServerError(text=f"could not save config: {e}") from None
return web.json_response({"ok": True})
# ------------------------------------------------------------ auth
async def login(self, request: web.Request) -> web.Response:
body = await self._json(request)
stored = self.col.cfg.web.get("password_hash", "")
if not stored:
raise web.HTTPForbidden(text="No web password set. On the server run: "
"meshcore_influx.py -c <config> --set-password")
if not mi.verify_password(str(body.get("password", "")), stored):
self.fail_delay = min(self.fail_delay + 1, 5)
await asyncio.sleep(self.fail_delay)
raise web.HTTPUnauthorized(text="wrong password")
self.fail_delay = 0
tok = secrets.token_urlsafe(32)
now = time.time()
self.sessions = {t: e for t, e in self.sessions.items() if e > now}
self.sessions[tok] = now + SESSION_TTL
resp = web.json_response({"ok": True})
resp.set_cookie(COOKIE, tok, max_age=SESSION_TTL, httponly=True, samesite="Strict",
secure=request.secure)
return resp
async def logout(self, request: web.Request) -> web.Response:
self.sessions.pop(request.cookies.get(COOKIE, ""), None)
resp = web.json_response({"ok": True})
resp.del_cookie(COOKIE)
return resp
async def change_password(self, request: web.Request) -> web.Response:
body = await self._json(request)
if not mi.verify_password(str(body.get("current", "")), self.col.cfg.web.get("password_hash", "")):
raise web.HTTPBadRequest(text="current password is wrong")
new = str(body.get("new", ""))
if len(new) < 8:
raise web.HTTPBadRequest(text="new password must be at least 8 characters")
raw = copy.deepcopy(self.col.raw)
raw.setdefault("web", {})["password_hash"] = mi.hash_password(new)
current = request.cookies.get(COOKIE, "")
self.sessions = {current: self.sessions[current]} if current in self.sessions else {}
return await self._apply(raw)
# ------------------------------------------------------------ read
async def status(self, request: web.Request) -> web.Response:
col, now = self.col, time.time()
nodes = []
for r in col.cfg.nodes:
st = col.states.get(r.name, mi.RepeaterState())
nodes.append({
"name": r.name, "type": r.kind, "enabled": r.enabled,
"low_power": bool(st.low_power) and r.battery.enabled,
"battery_mv": st.battery_mv,
"interval": st.interval(r),
"last_poll": st.last_poll or None,
"last_success": st.last_success or None,
"next_due": st.next_due(r) if r.enabled else None,
"polling": col.polling == r.name,
"queued": r.name in col.force,
"result": st.last_result,
})
return web.json_response({
"now": now,
"companion": {**col.companion, "configured": col.cfg.companion_configured},
"influx": {**col.influx_status, "configured": col.cfg.influx_configured,
"url": col.cfg.influx.get("url", ""), "bucket": col.cfg.influx.get("bucket", "")},
"nodes": nodes,
"local": {**col.local, "interval": col.cfg.local_interval,
"pubkey": (col.mc.self_info or {}).get("public_key", "")[:12] if col.mc else ""},
"advert": {**col.advert, "interval_hours": col.cfg.advert_hours, "flood_default": col.cfg.advert_flood,
"next_auto": col.advert_next_due()},
"dry_run": col.dry_run,
})
async def get_config(self, request: web.Request) -> web.Response:
raw = self.col.raw
poll = {k: v for k, v in raw.get("poll", {}).items() if k in POLL_KEYS}
return web.json_response({
"connection": self._mask(raw.get("connection", {})),
"influxdb": self._mask(raw.get("influxdb", {})),
"poll": poll,
"nodes": [{**self._mask(r), "type": kind} for kind in NODE_SCHEMAS for r in raw.get(kind, [])],
"advert": raw.get("advert", {}),
"web": {k: v for k, v in raw.get("web", {}).items() if k != "password_hash"},
})
async def contacts(self, request: web.Request) -> web.Response:
configured = {r.name.lower() for r in self.col.cfg.nodes}
prefixes = [r.pubkey_prefix for r in self.col.cfg.nodes if r.pubkey_prefix]
out = []
for c in self.col.contacts():
c["configured"] = c["name"].lower() in configured or any(c["pubkey"].startswith(p) for p in prefixes)
out.append(c)
out.sort(key=lambda c: ({2: 0, 1: 1}.get(c["type"], 2), -c["last_advert"]))
return web.json_response({"connected": self.col.companion["connected"], "contacts": out})
async def serial_ports(self, request: web.Request) -> web.Response:
ports = []
for path in sorted(glob.glob("/dev/serial/by-id/*")):
ports.append({"path": path, "desc": os.path.basename(path), "target": os.path.realpath(path)})
try:
from serial.tools import list_ports
seen = {p["target"] for p in ports}
for p in list_ports.comports():
if p.device not in seen:
ports.append({"path": p.device, "desc": p.description or "", "target": p.device})
except Exception: # noqa: BLE001 - pyserial missing or platform quirk
pass
return web.json_response({"ports": ports})
async def logs(self, request: web.Request) -> web.Response:
return web.json_response({"lines": list(self.logbuf.lines)[-300:]})
# ------------------------------------------------------------ write
async def put_section(self, request: web.Request) -> web.Response:
section = request.match_info["section"]
schema = {"connection": CONNECTION_KEYS, "influxdb": INFLUX_KEYS, "poll": POLL_KEYS,
"advert": ADVERT_KEYS}.get(section)
if schema is None:
raise web.HTTPNotFound()
body = await self._json(request)
raw = copy.deepcopy(self.col.raw)
old = raw.get(section, {})
new = self._clean(body, schema, old)
# keep keys we don't manage from the UI (e.g. poll.state_file)
new.update({k: v for k, v in old.items() if k not in schema})
raw[section] = new
return await self._apply(raw)
async def test_influx(self, request: web.Request) -> web.Response:
body = await self._json(request)
ic = self._clean(body, INFLUX_KEYS, self.col.raw.get("influxdb", {}))
return web.json_response(await mi.influx_test(ic))
def _find(self, raw: dict, name: str) -> tuple[str, int]:
for kind in NODE_SCHEMAS:
for i, r in enumerate(raw.get(kind, [])):
if r.get("name", "").lower() == name.lower():
return kind, i
raise web.HTTPNotFound(text=f"no node named {name!r}")
async def add_node(self, request: web.Request) -> web.Response:
body = await self._json(request)
kind = body.pop("type", "repeater")
if kind not in NODE_SCHEMAS:
raise web.HTTPBadRequest(text="type must be repeater or companion")
raw = copy.deepcopy(self.col.raw)
raw.setdefault(kind, []).append(self._clean(body, NODE_SCHEMAS[kind], {}))
resp = await self._apply(raw)
self.col.request_poll(raw[kind][-1]["name"])
return resp
async def update_node(self, request: web.Request) -> web.Response:
body = await self._json(request)
body.pop("type", None) # a node's type can't change; delete and re-add instead
raw = copy.deepcopy(self.col.raw)
kind, i = self._find(raw, request.match_info["name"])
old = raw[kind][i]
# Override fields absent from the body mean "inherit": don't carry the old value over.
keep = {k: v for k, v in old.items() if k in ("password",) or k in body}
new = self._clean(body, NODE_SCHEMAS[kind], keep)
raw[kind][i] = new
renamed = {old["name"]: new["name"]} if new.get("name") and new["name"] != old["name"] else None
return await self._apply(raw, renamed)
async def delete_node(self, request: web.Request) -> web.Response:
raw = copy.deepcopy(self.col.raw)
kind, i = self._find(raw, request.match_info["name"])
del raw[kind][i]
return await self._apply(raw)
async def poll_now(self, request: web.Request) -> web.Response:
name = request.match_info["name"]
if not any(r.name == name for r in self.col.cfg.nodes):
raise web.HTTPNotFound(text=f"no node named {name!r}")
self.col.request_poll(name)
return web.json_response({"ok": True})
async def poll_local(self, request: web.Request) -> web.Response:
self.col._local_last = 0.0
self.col.wake.set()
return web.json_response({"ok": True})
async def send_advert(self, request: web.Request) -> web.Response:
body = await self._json(request)
if not self.col.companion["connected"]:
raise web.HTTPConflict(text="companion radio not connected")
self.col.request_advert(bool(body.get("flood", False)))
return web.json_response({"ok": True, "queued": True})
async def contact_qr(self, request: web.Request) -> web.Response:
uri = self.col.companion.get("uri")
if not uri:
raise web.HTTPNotFound(text="contact card not available (companion not connected?)")
import io
import segno
buf = io.BytesIO()
segno.make(uri, error="m").save(buf, kind="svg", scale=5, border=2, dark="#000", light="#fff",
xmldecl=False, svgns=True)
return web.Response(body=buf.getvalue(), content_type="image/svg+xml")
async def reconnect(self, request: web.Request) -> web.Response:
self.col._reconnect = True
self.col._next_connect = 0.0
self.col.wake.set()
return web.json_response({"ok": True})
@web.middleware
async def auth_mw(request: web.Request, handler):
api: Api = request.app["api"]
api.col.reload_if_changed()
path = request.path
if path.startswith("/api/") and path not in ("/api/login", "/api/session"):
if not api._authed(request):
raise web.HTTPUnauthorized(text="login required")
resp = await handler(request)
resp.headers.setdefault("X-Content-Type-Options", "nosniff")
resp.headers.setdefault("X-Frame-Options", "DENY")
resp.headers.setdefault("Referrer-Policy", "same-origin")
if path.startswith("/api/"):
resp.headers.setdefault("Cache-Control", "no-store")
return resp
def build_app(col: "mi.Collector", logbuf: "mi.LogBuffer") -> web.Application:
app = web.Application(middlewares=[auth_mw], client_max_size=64 * 1024)
api = Api(col, logbuf)
app["api"] = api
async def index(request):
return web.FileResponse(STATIC / "index.html", headers={"Cache-Control": "no-cache"})
async def session(request):
return web.json_response({"authed": api._authed(request),
"password_set": bool(col.cfg.web.get("password_hash"))})
app.router.add_get("/", index)
app.router.add_get("/api/session", session)
app.router.add_post("/api/login", api.login)
app.router.add_post("/api/logout", api.logout)
app.router.add_post("/api/password", api.change_password)
app.router.add_get("/api/status", api.status)
app.router.add_get("/api/config", api.get_config)
app.router.add_put("/api/config/{section}", api.put_section)
app.router.add_post("/api/influxdb/test", api.test_influx)
app.router.add_get("/api/contacts", api.contacts)
app.router.add_get("/api/serial-ports", api.serial_ports)
app.router.add_get("/api/logs", api.logs)
app.router.add_post("/api/companion/reconnect", api.reconnect)
app.router.add_post("/api/companion/refresh", api.poll_local)
app.router.add_post("/api/companion/advert", api.send_advert)
app.router.add_get("/api/companion/qr.svg", api.contact_qr)
app.router.add_post("/api/nodes", api.add_node)
app.router.add_put("/api/nodes/{name}", api.update_node)
app.router.add_delete("/api/nodes/{name}", api.delete_node)
app.router.add_post("/api/nodes/{name}/poll", api.poll_now)
return app
async def start(col: "mi.Collector", logbuf: "mi.LogBuffer") -> web.AppRunner:
host = col.cfg.web.get("listen", "0.0.0.0")
port = int(col.cfg.web.get("port", 8080))
runner = web.AppRunner(build_app(col, logbuf), access_log=None)
await runner.setup()
await web.TCPSite(runner, host, port).start()
log.info("web UI listening on http://%s:%d/", host, port)
if not col.cfg.web.get("password_hash"):
log.warning("no web UI password set; run with --set-password to enable login")
return runner