diff options
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/api')
5 files changed, 88 insertions, 10 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/admin.py b/packages/meshbay-hub/src/meshbay_hub/api/admin.py index 785731d..7ee05ca 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/admin.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/admin.py @@ -15,7 +15,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from meshbay_hub.auth import decrypt_email from meshbay_hub.api.deps import require_admin, require_moderator -from meshbay_hub.api.revocation import get_connected_node_count +from meshbay_hub.api.revocation import get_connected_node_count, is_node_connected from meshbay_hub.db.engine import get_db from meshbay_hub.db.models import Group, GroupMember, IPLog, Node, User @@ -42,8 +42,18 @@ async def admin_stats( current_user: User = Depends(require_moderator), db: AsyncSession = Depends(get_db), ): - user_count = (await db.execute(select(func.count()).select_from(User))).scalar_one() - group_count = (await db.execute(select(func.count()).select_from(Group))).scalar_one() + # Deleted accounts are tombstoned rather than dropped, so that the + # connection log stays readable. They are not users any more and must not be + # counted as any: a hub whose user count only ever rises is measuring its + # own history, not its population. + user_count = (await db.execute( + select(func.count()).select_from(User) + .where(User.status != "deleted"))).scalar_one() + # Groups are not tombstoned — deleting one removes the row — so every group + # here is a group. A revoked one is suspended by moderation and still shown + # in the list, so counting it keeps the two consistent. + group_count = (await db.execute( + select(func.count()).select_from(Group))).scalar_one() node_count = (await db.execute(select(func.count()).select_from(Node))).scalar_one() return { "users": user_count, @@ -63,14 +73,16 @@ async def admin_list_users( offset: int = 0, limit: int = Query(default=50, le=200), ): - query = select(User).order_by(User.created_at.desc()) + query = (select(User).where(User.status != "deleted") + .order_by(User.created_at.desc())) if q: query = query.where(User.username.ilike(f"%{q}%")) query = query.offset(offset).limit(limit) result = await db.execute(query) users = result.scalars().all() - total_query = select(func.count()).select_from(User) + total_query = (select(func.count()).select_from(User) + .where(User.status != "deleted")) if q: total_query = total_query.where(User.username.ilike(f"%{q}%")) total = (await db.execute(total_query)).scalar_one() @@ -222,9 +234,13 @@ async def admin_list_groups( query = ( select( Group, - func.count(GroupMember.user_id).label("member_count"), + func.count(User.id).label("member_count"), ) + # Members, not rows: a deleted account's membership is removed with it, + # but joining through User keeps the count honest if one ever survives. .outerjoin(GroupMember, Group.id == GroupMember.group_id) + .outerjoin(User, (User.id == GroupMember.user_id) + & (User.status != "deleted")) .group_by(Group.id) .order_by(Group.created_at.desc()) .offset(offset) @@ -288,6 +304,44 @@ async def admin_patch_group( # ── IP Audit Logs ──────────────────────────────────────────────────────────── +@router.get("/nodes") +async def admin_list_nodes( + current_user: User = Depends(require_moderator), + db: AsyncSession = Depends(get_db), + limit: int = Query(default=100, le=200), +): + """ + Registered nodes, with the address the hub saw them announce from. + + `observed_ip` is the one to answer a question with: it comes from the + connection that carried a valid Ed25519 signature over a fresh timestamp, so + it is the address of whoever holds the node key. `endpoint_hint` is what the + node believes its own address to be, discovered through a STUN server and + sent to us — useful for reaching it, and not evidence of anything. + """ + rows = (await db.execute( + select(Node, User.username) + .outerjoin(User, User.id == Node.user_id) + .order_by(Node.announced_at.desc()) + .limit(limit))).all() + return { + "nodes": [ + { + "id": n.id, + "user_id": n.user_id, + "username": uname or "", + "pk_node": n.pk_node, + "observed_ip": n.observed_ip or "", + "endpoint_hint": n.endpoint_hint or "", + "last_seen": n.last_seen.isoformat() if n.last_seen else "", + "announced_at": n.announced_at.isoformat(), + "online": is_node_connected(n.id), + } + for n, uname in rows + ], + } + + @router.get("/logs") async def admin_list_logs( current_user: User = Depends(require_moderator), diff --git a/packages/meshbay-hub/src/meshbay_hub/api/groups.py b/packages/meshbay-hub/src/meshbay_hub/api/groups.py index 74c6c9e..8e3197c 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/groups.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/groups.py @@ -207,7 +207,7 @@ async def group_members( result = await db.execute( select(User.id, User.username) .join(GroupMember, User.id == GroupMember.user_id) - .where(GroupMember.group_id == group_id) + .where(GroupMember.group_id == group_id, User.status != "deleted") ) members = [{"user_id": uid, "username": uname} for uid, uname in result.all()] return { diff --git a/packages/meshbay-hub/src/meshbay_hub/api/nodes.py b/packages/meshbay-hub/src/meshbay_hub/api/nodes.py index 0770148..67e65f2 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/nodes.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/nodes.py @@ -1,5 +1,6 @@ """Node endpoints — /v1/nodes/*""" +from datetime import datetime, timezone import base64 import time @@ -130,10 +131,18 @@ async def announce_node( select(Node).where(Node.user_id == current_user.id, Node.pk_node == body.pk_node)) node = existing.scalar_one_or_none() + # The address is taken from the connection, never from the body: the + # signature above proves who is announcing, and this is where they are + # announcing from. What the node believes its address to be — endpoint_hint, + # learned from a STUN server — is kept separately and is not evidence. + seen_from = client_ip(request) + if node is not None: node.endpoint_hint = body.endpoint_hint + node.observed_ip = seen_from + node.last_seen = datetime.now(timezone.utc) db.add(IPLog(user_id=current_user.id, event="node_announce", - ip_address=client_ip(request), detail=body.endpoint_hint)) + ip_address=seen_from, detail=body.endpoint_hint)) await db.commit() return {"node_id": node.id} @@ -141,12 +150,14 @@ async def announce_node( user_id=current_user.id, pk_node=body.pk_node, endpoint_hint=body.endpoint_hint, + observed_ip=seen_from, + last_seen=datetime.now(timezone.utc), ) db.add(node) db.add(IPLog( user_id=current_user.id, event="node_announce", - ip_address=client_ip(request), + ip_address=seen_from, detail=body.endpoint_hint, )) await db.commit() diff --git a/packages/meshbay-hub/src/meshbay_hub/api/revocation.py b/packages/meshbay-hub/src/meshbay_hub/api/revocation.py index 58ebf50..1f6d7f0 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/revocation.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/revocation.py @@ -55,6 +55,10 @@ _node_groups: dict[str, list[str]] = {} # node_id → [group_id, ...] _punch_events: dict[str, asyncio.Event] = {} # node_id → signaling event +def is_node_connected(node_id: str) -> bool: + return node_id in _connected_nodes + + def get_connected_node_count() -> int: return len(_connected_nodes) diff --git a/packages/meshbay-hub/src/meshbay_hub/api/signaling.py b/packages/meshbay-hub/src/meshbay_hub/api/signaling.py index 8f84163..cb00a67 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/signaling.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/signaling.py @@ -25,7 +25,8 @@ from sqlalchemy.ext.asyncio import AsyncSession 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 Group, GroupMember, User +from meshbay_hub.api.netutil import client_ip +from meshbay_hub.db.models import Group, GroupMember, IPLog, User log = logging.getLogger(__name__) @@ -77,6 +78,14 @@ async def webrtc_offer( if len(body.sdp) > MAX_SDP_BYTES: raise HTTPException(status_code=413, detail="SDP too large") + # Logged here because this is the moment a browser starts a peer connection, + # and the address it starts it from is this one — the hub's own view of the + # TCP connection. Whatever address the peers then discover through STUN is + # theirs to negotiate and is not what a log should record. + db.add(IPLog(user_id=current_user.id, event="webrtc_offer", + ip_address=client_ip(request), detail=node_id[:8])) + await db.commit() + ws = _connected_nodes.get(node_id) if not ws: raise HTTPException(status_code=404, detail="Node not connected") |