401 lines
16 KiB
Python
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()
|