aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/api
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/api')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/admin.py66
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/groups.py2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/nodes.py15
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/revocation.py4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/signaling.py11
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")