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.
388 lines
17 KiB
Python
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
|