"""Node endpoints — /v1/nodes/*""" import base64 import time from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey from cryptography.exceptions import InvalidSignature from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from meshbay_hub.auth import issue_access_token from meshbay_hub.api.deps import get_current_user from meshbay_hub.api.middleware import limiter from meshbay_hub.db.engine import get_db from meshbay_hub.db.models import GroupMember, IPLog, Node, User router = APIRouter(prefix="/v1/nodes", tags=["nodes"]) NODE_AUTH_TIMESTAMP_WINDOW = 60 # seconds class NodeAuthRequest(BaseModel): username: str timestamp: int # unix epoch seconds signature: str # base64 Ed25519 signature @router.post("/auth") @limiter.limit("10/minute") async def node_auth( body: NodeAuthRequest, request: Request, db: AsyncSession = Depends(get_db), ): """Authenticate a node daemon via Ed25519 challenge-response. Returns node-scoped JWT.""" now = int(time.time()) if abs(now - body.timestamp) > NODE_AUTH_TIMESTAMP_WINDOW: raise HTTPException(status_code=401, detail="Timestamp too old or too far in the future") result = await db.execute(select(User).where(User.username == body.username)) user = result.scalar_one_or_none() if not user: raise HTTPException(status_code=401, detail="Invalid credentials") if user.status != "active": raise HTTPException(status_code=403, detail=f"Account {user.status}") if not user.pk_node_ed25519: raise HTTPException( status_code=401, detail="No node key registered — link your node from the browser first", ) message = f"meshbay:node_auth:{body.username}:{body.timestamp}".encode() try: pk_raw = base64.b64decode(user.pk_node_ed25519) pk = Ed25519PublicKey.from_public_bytes(pk_raw) sig = base64.b64decode(body.signature) pk.verify(sig, message) except (InvalidSignature, Exception): db.add(IPLog(event="node_auth_fail", ip_address=_ip(request), detail=body.username)) await db.commit() raise HTTPException(status_code=401, detail="Invalid signature") memberships = await db.execute( select(GroupMember.group_id).where(GroupMember.user_id == user.id)) group_ids = [gid for (gid,) in memberships.all()] access_token = issue_access_token( user.id, user.pk_node_ed25519, ttl=3600, groups=group_ids, scope="node") db.add(IPLog(user_id=user.id, event="node_auth", ip_address=_ip(request))) await db.commit() return { "access_token": access_token, "token_type": "bearer", "expires_in": 3600, } class NodeAnnounceRequest(BaseModel): pk_node: str endpoint_hint: str | None = None @router.post("/announce", status_code=201) async def announce_node( body: NodeAnnounceRequest, request: Request, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): node = Node( user_id=current_user.id, pk_node=body.pk_node, endpoint_hint=body.endpoint_hint, ) db.add(node) db.add(IPLog( user_id=current_user.id, event="node_announce", ip_address=_ip(request), detail=body.endpoint_hint, )) await db.commit() await db.refresh(node) return {"node_id": node.id} @router.get("/{node_id}") async def get_node( node_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): node = await db.get(Node, node_id) if not node: raise HTTPException(status_code=404, detail="Node not found") owner = await db.get(User, node.user_id) return { "node_id": node.id, "username": owner.username if owner else "", "pk_node": node.pk_node, "endpoint_hint": node.endpoint_hint, "announced_at": node.announced_at.isoformat(), } def _ip(request: Request) -> str: fwd = request.headers.get("X-Forwarded-For") if fwd: return fwd.split(",")[0].strip() return request.client.host if request.client else "unknown"