summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/api/nodes.py
blob: 321e43c056f46589a0b1419e7bfcf955c2e0f9f4 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
"""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"