rmesh-reticulum/app/main.py

401 lines
16 KiB
Python

"""
rMesh Bridge — Unified REST + WebSocket API for Reticulum (Internet backbone),
MeshCore (LoRa mesh), LXMF messaging, audio calls, and NomadNet browsing.
"""
import asyncio
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, Header, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse
from pydantic import BaseModel
from typing import Annotated, Optional
from . import reticulum_bridge, lxmf_bridge, meshcore_bridge, websocket_manager, rchats_bridge
from .config import BRIDGE_API_KEY, MESHCORE_ENABLED, RCHATS_BRIDGE_ENABLED
from .models import (
StatusResponse, NodesResponse, TopologyResponse,
MessageIn, MessageOut, MessagesResponse,
IdentityResponse, AnnounceResponse, HealthResponse,
)
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s: %(message)s")
logger = logging.getLogger("rmesh.api")
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Initialize all subsystems on startup."""
logger.info("Starting rMesh bridge...")
# Set up WebSocket event loop
loop = asyncio.get_event_loop()
websocket_manager.set_event_loop(loop)
# Register WebSocket broadcast as event listener for all subsystems
reticulum_bridge.register_event_listener(websocket_manager.on_event)
lxmf_bridge.register_event_listener(websocket_manager.on_event)
# Register rChats bridge as event listener
if RCHATS_BRIDGE_ENABLED:
lxmf_bridge.register_event_listener(rchats_bridge.on_lxmf_event)
rchats_bridge.init()
# Reticulum (Internet backbone)
reticulum_bridge.init()
from .reticulum_bridge import _identity
if _identity:
lxmf_bridge.init(_identity)
else:
logger.error("No Reticulum identity available for LXMF")
reticulum_bridge.announce()
reticulum_bridge.announce_call_capability()
# MeshCore (LoRa mesh)
if MESHCORE_ENABLED:
await meshcore_bridge.init()
else:
logger.info("MeshCore disabled (set MESHCORE_ENABLED=true to activate)")
yield
logger.info("Shutting down rMesh bridge")
if MESHCORE_ENABLED:
await meshcore_bridge.disconnect()
app = FastAPI(title="rMesh Bridge", lifespan=lifespan)
def verify_api_key(x_bridge_api_key: Annotated[str, Header()] = ""):
if not BRIDGE_API_KEY:
return
if x_bridge_api_key != BRIDGE_API_KEY:
raise HTTPException(status_code=401, detail="Invalid API key")
# ═══════════════════════════════════════════════════════════════════
# WebSocket (real-time events)
# ═══════════════════════════════════════════════════════════════════
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
await websocket_manager.connect(websocket)
try:
while True:
data = await websocket.receive_text()
# Handle ping/pong
if data == "ping":
await websocket.send_text('{"type":"pong"}')
except WebSocketDisconnect:
websocket_manager.disconnect(websocket)
except Exception:
websocket_manager.disconnect(websocket)
# ═══════════════════════════════════════════════════════════════════
# Health & Combined Status
# ═══════════════════════════════════════════════════════════════════
@app.get("/api/health", response_model=HealthResponse)
async def health():
return {"status": "ok"}
@app.get("/api/status")
async def combined_status(x_bridge_api_key: Annotated[str, Header()] = ""):
verify_api_key(x_bridge_api_key)
return {
"reticulum": reticulum_bridge.get_status(),
"meshcore": meshcore_bridge.get_status() if MESHCORE_ENABLED else {
"connected": False, "device_info": {}, "contact_count": 0, "message_count": 0
},
"propagation": lxmf_bridge.get_propagation_status(),
"websocket_clients": websocket_manager.client_count(),
}
# ═══════════════════════════════════════════════════════════════════
# Reticulum Endpoints
# ═══════════════════════════════════════════════════════════════════
@app.get("/api/reticulum/status", response_model=StatusResponse)
async def reticulum_status(x_bridge_api_key: Annotated[str, Header()] = ""):
verify_api_key(x_bridge_api_key)
return reticulum_bridge.get_status()
@app.get("/api/reticulum/nodes")
async def reticulum_nodes(x_bridge_api_key: Annotated[str, Header()] = ""):
verify_api_key(x_bridge_api_key)
node_list = reticulum_bridge.get_nodes()
return {"nodes": node_list, "total": len(node_list)}
@app.get("/api/reticulum/topology")
async def reticulum_topology(x_bridge_api_key: Annotated[str, Header()] = ""):
verify_api_key(x_bridge_api_key)
return reticulum_bridge.get_topology()
@app.post("/api/reticulum/announce", response_model=AnnounceResponse)
async def reticulum_announce(x_bridge_api_key: Annotated[str, Header()] = ""):
verify_api_key(x_bridge_api_key)
return reticulum_bridge.announce()
@app.get("/api/reticulum/identity", response_model=IdentityResponse)
async def reticulum_identity(x_bridge_api_key: Annotated[str, Header()] = ""):
verify_api_key(x_bridge_api_key)
return reticulum_bridge.get_identity_info()
# ═══════════════════════════════════════════════════════════════════
# LXMF Messages (with rich fields)
# ═══════════════════════════════════════════════════════════════════
@app.get("/api/reticulum/messages")
async def reticulum_messages(
x_bridge_api_key: Annotated[str, Header()] = "",
limit: int = 100, offset: int = 0,
):
verify_api_key(x_bridge_api_key)
msgs = lxmf_bridge.get_messages(limit=limit, offset=offset)
return {"messages": msgs, "total": lxmf_bridge.get_total_count()}
@app.post("/api/reticulum/messages")
async def reticulum_send_message(
body: MessageIn,
x_bridge_api_key: Annotated[str, Header()] = "",
):
verify_api_key(x_bridge_api_key)
result = lxmf_bridge.send_message(
destination_hash_hex=body.destination_hash,
content=body.content,
title=body.title,
)
if not result.get("id"):
raise HTTPException(status_code=400, detail="Failed to send message")
return result
@app.get("/api/reticulum/messages/{msg_id}")
async def reticulum_get_message(msg_id: str, x_bridge_api_key: Annotated[str, Header()] = ""):
verify_api_key(x_bridge_api_key)
msg = lxmf_bridge.get_message(msg_id)
if msg is None:
raise HTTPException(status_code=404, detail="Message not found")
return msg
@app.get("/api/attachments/{filename}")
async def get_attachment(filename: str, x_bridge_api_key: Annotated[str, Header()] = ""):
verify_api_key(x_bridge_api_key)
# Sanitize filename
safe = "".join(c for c in filename if c.isalnum() or c in ".-_")
path = lxmf_bridge.get_attachment_path(safe)
if path is None:
raise HTTPException(status_code=404, detail="Attachment not found")
return FileResponse(path)
# ═══════════════════════════════════════════════════════════════════
# Propagation Node Management
# ═══════════════════════════════════════════════════════════════════
@app.get("/api/propagation/status")
async def propagation_status(x_bridge_api_key: Annotated[str, Header()] = ""):
verify_api_key(x_bridge_api_key)
return lxmf_bridge.get_propagation_status()
@app.get("/api/propagation/nodes")
async def propagation_nodes(x_bridge_api_key: Annotated[str, Header()] = ""):
verify_api_key(x_bridge_api_key)
nodes = lxmf_bridge.get_propagation_nodes()
return {"nodes": nodes, "total": len(nodes)}
class PropagationNodeSet(BaseModel):
destination_hash: str
@app.post("/api/propagation/preferred")
async def set_preferred_propagation(body: PropagationNodeSet, x_bridge_api_key: Annotated[str, Header()] = ""):
verify_api_key(x_bridge_api_key)
ok = lxmf_bridge.set_preferred_propagation_node(body.destination_hash)
if not ok:
raise HTTPException(status_code=400, detail="Failed to set propagation node")
return {"status": "ok", "destination_hash": body.destination_hash}
@app.post("/api/propagation/sync")
async def sync_propagation(x_bridge_api_key: Annotated[str, Header()] = ""):
verify_api_key(x_bridge_api_key)
lxmf_bridge.sync_propagation_messages()
return {"status": "syncing"}
# ═══════════════════════════════════════════════════════════════════
# Audio Calls
# ═══════════════════════════════════════════════════════════════════
class CallInitiate(BaseModel):
destination_hash: str
@app.get("/api/calls")
async def list_calls(x_bridge_api_key: Annotated[str, Header()] = ""):
verify_api_key(x_bridge_api_key)
return {"calls": reticulum_bridge.get_active_calls()}
@app.post("/api/calls")
async def initiate_call(body: CallInitiate, x_bridge_api_key: Annotated[str, Header()] = ""):
verify_api_key(x_bridge_api_key)
result = reticulum_bridge.initiate_call(body.destination_hash)
if not result.get("call_id"):
raise HTTPException(status_code=400, detail=result.get("error", "Call failed"))
return result
class CallHangup(BaseModel):
call_id: str
@app.post("/api/calls/hangup")
async def hangup_call(body: CallHangup, x_bridge_api_key: Annotated[str, Header()] = ""):
verify_api_key(x_bridge_api_key)
reticulum_bridge.hangup_call(body.call_id)
return {"status": "ok"}
# ═══════════════════════════════════════════════════════════════════
# NomadNet Node Browser
# ═══════════════════════════════════════════════════════════════════
@app.get("/api/nomadnet/nodes")
async def nomadnet_nodes(x_bridge_api_key: Annotated[str, Header()] = ""):
verify_api_key(x_bridge_api_key)
nodes = reticulum_bridge.get_nomadnet_nodes()
return {"nodes": nodes, "total": len(nodes)}
class NomadNetBrowse(BaseModel):
destination_hash: str
path: str = "/"
@app.post("/api/nomadnet/browse")
async def nomadnet_browse(body: NomadNetBrowse, x_bridge_api_key: Annotated[str, Header()] = ""):
verify_api_key(x_bridge_api_key)
result = reticulum_bridge.browse_nomadnet_node(body.destination_hash, body.path)
if "error" in result:
raise HTTPException(status_code=400, detail=result["error"])
return result
# ═══════════════════════════════════════════════════════════════════
# MeshCore Endpoints (LoRa mesh)
# ═══════════════════════════════════════════════════════════════════
class MCMessageIn(BaseModel):
contact_name: str
content: str
class MCChannelMessageIn(BaseModel):
channel_idx: int
content: str
@app.get("/api/meshcore/status")
async def meshcore_status(x_bridge_api_key: Annotated[str, Header()] = ""):
verify_api_key(x_bridge_api_key)
return meshcore_bridge.get_status()
@app.get("/api/meshcore/contacts")
async def meshcore_contacts(x_bridge_api_key: Annotated[str, Header()] = ""):
verify_api_key(x_bridge_api_key)
contacts = meshcore_bridge.get_contacts()
return {"contacts": contacts, "total": len(contacts)}
@app.post("/api/meshcore/contacts/refresh")
async def meshcore_refresh_contacts(x_bridge_api_key: Annotated[str, Header()] = ""):
verify_api_key(x_bridge_api_key)
contacts = await meshcore_bridge.refresh_contacts()
return {"contacts": contacts, "total": len(contacts)}
@app.get("/api/meshcore/messages")
async def meshcore_messages(
x_bridge_api_key: Annotated[str, Header()] = "",
limit: int = 100, offset: int = 0,
):
verify_api_key(x_bridge_api_key)
msgs = meshcore_bridge.get_messages(limit=limit, offset=offset)
return {"messages": msgs, "total": meshcore_bridge.get_message_count()}
@app.post("/api/meshcore/messages")
async def meshcore_send_message(
body: MCMessageIn,
x_bridge_api_key: Annotated[str, Header()] = "",
):
verify_api_key(x_bridge_api_key)
result = await meshcore_bridge.send_message(body.contact_name, body.content)
if not result.get("id"):
raise HTTPException(status_code=400, detail=result.get("error", "Failed to send"))
return result
@app.post("/api/meshcore/channels/messages")
async def meshcore_send_channel_message(
body: MCChannelMessageIn,
x_bridge_api_key: Annotated[str, Header()] = "",
):
verify_api_key(x_bridge_api_key)
result = await meshcore_bridge.send_channel_message(body.channel_idx, body.content)
if not result.get("id"):
raise HTTPException(status_code=400, detail=result.get("error", "Failed to send"))
return result
@app.post("/api/meshcore/advert")
async def meshcore_advert(x_bridge_api_key: Annotated[str, Header()] = ""):
verify_api_key(x_bridge_api_key)
return await meshcore_bridge.send_advert()
@app.get("/api/meshcore/stats")
async def meshcore_stats(x_bridge_api_key: Annotated[str, Header()] = ""):
verify_api_key(x_bridge_api_key)
return await meshcore_bridge.get_device_stats()
# ═══════════════════════════════════════════════════════════════════
# Backwards Compatibility Aliases
# ═══════════════════════════════════════════════════════════════════
@app.get("/api/nodes")
async def nodes_compat(x_bridge_api_key: Annotated[str, Header()] = ""):
verify_api_key(x_bridge_api_key)
node_list = reticulum_bridge.get_nodes()
return {"nodes": node_list, "total": len(node_list)}
@app.get("/api/topology")
async def topology_compat(x_bridge_api_key: Annotated[str, Header()] = ""):
verify_api_key(x_bridge_api_key)
return reticulum_bridge.get_topology()
@app.get("/api/identity")
async def identity_compat(x_bridge_api_key: Annotated[str, Header()] = ""):
verify_api_key(x_bridge_api_key)
return reticulum_bridge.get_identity_info()