From 91505face56f7ee6817408e52bad7902add75f09 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Mon, 28 Sep 2026 21:35:20 +0200 Subject: refactor: remove the public-content swarm Nodes registered the hashes of their public groups on the hub and nothing ever read them back. Routes, model and node registration removed; a migration drops swarm_sources. No node sends the hub a content hash now. Co-Authored-By: Claude Opus 5.5 --- packages/meshbay-hub/src/meshbay_hub/api/groups.py | 108 --------------------- packages/meshbay-hub/src/meshbay_hub/api/users.py | 7 +- packages/meshbay-hub/src/meshbay_hub/app.py | 2 - .../versions/e6f7a8b9c0d1_drop_swarm_sources.py | 38 ++++++++ packages/meshbay-hub/src/meshbay_hub/db/models.py | 16 --- 5 files changed, 40 insertions(+), 131 deletions(-) create mode 100644 packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/e6f7a8b9c0d1_drop_swarm_sources.py (limited to 'packages/meshbay-hub/src') diff --git a/packages/meshbay-hub/src/meshbay_hub/api/groups.py b/packages/meshbay-hub/src/meshbay_hub/api/groups.py index 52d9f60..df2f336 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/groups.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/groups.py @@ -20,16 +20,11 @@ from meshbay_hub.db.models import ( Group, GroupMember, IPLog, - SwarmSource, User, ) router = APIRouter(prefix="/v1/groups", tags=["groups"]) -# Swarm endpoints live at /v1/swarm/*. They were previously declared on the groups -# router with a full path, which mounted them at /v1/groups/v1/swarm/* (H7). -swarm_router = APIRouter(prefix="/v1/swarm", tags=["swarm"]) - @router.get("/mine") async def my_groups( @@ -211,109 +206,6 @@ async def list_public_groups( return {"groups": groups, "total": len(groups)} -# ── Swarm (content replication) ─────────────────────────────────────────────── - -class SwarmRegisterRequest(BaseModel): - content_hash: str # blake3 hex - endpoint: str # ":" — a port on the caller, never a host - - -# A transport and a port, and deliberately no host. The field used to be free -# text documented as "ip:port", so a caller could name *someone else's* -# address as a source; nothing dials a swarm source today, which is the only -# reason that was not already a reflection primitive. A reader learns where a -# node is from the node record, which is stamped with the address the announce -# actually came from — so a host here would be a second, weaker, answer to a -# question already settled elsewhere. -_SWARM_ENDPOINT = re.compile(r"^(webrtc|quic):([0-9]{1,5})$") - -# One account, this many public hashes. Rows are keyed (hash, account) with no -# cap, so a loop of invented hashes was unbounded storage growth on a hub -# shared with everyone else. A public library far larger than this is a real -# thing — but it is one a hub operator should be asked about, not something a -# client establishes by writing rows. -MAX_SWARM_HASHES_PER_ACCOUNT = 10_000 - - -@swarm_router.post("/register", status_code=201) -@limiter.limit("120/minute") -async def swarm_register( - body: SwarmRegisterRequest, - request: Request, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """ - Node registers itself as a source for a PUBLIC content hash. - - Finding H7: the node registered hashes for every group it hosted, private ones - included, and this route was mounted at /v1/groups/v1/swarm/register — so the - node's calls 404'd and the leak was masked by a routing bug rather than - prevented. Nodes now filter by group visibility before calling, and the path is - correct, so the filter has to be right. - - Availability: the endpoint is a port, not an address, and the number of - hashes one account may claim is bounded. See the two constants above. - """ - m = _SWARM_ENDPOINT.match(body.endpoint or "") - if not m or not (0 < int(m.group(2)) < 65536): - raise HTTPException( - status_code=422, - detail="endpoint must be ':' — a port on the " - "registering node, not an address") - - from datetime import datetime - existing = await db.get(SwarmSource, (body.content_hash, current_user.id)) - now = datetime.now(UTC) - if existing: - existing.endpoint = body.endpoint - existing.last_seen = now - else: - held = (await db.execute( - select(func.count()).select_from(SwarmSource) - .where(SwarmSource.node_id == current_user.id))).scalar() or 0 - if held >= MAX_SWARM_HASHES_PER_ACCOUNT: - raise HTTPException( - status_code=429, - detail="This account already claims the maximum number of " - "public content hashes") - db.add(SwarmSource( - content_hash=body.content_hash, - node_id=current_user.id, - endpoint=body.endpoint, - )) - await db.commit() - return {"status": "registered", "hash": body.content_hash} - - -@swarm_router.get("/{content_hash}") -async def swarm_sources( - content_hash: str, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """ - Return nodes that can serve a content hash. - - Authenticated (H7): an open endpoint lets anyone probe whether a given file - exists anywhere in the network and which node holds it. - """ - from datetime import datetime, timedelta - cutoff = datetime.now(UTC) - timedelta(minutes=30) - result = await db.execute( - select(SwarmSource) - .where( - SwarmSource.content_hash == content_hash, - SwarmSource.last_seen > cutoff, - ) - ) - sources = result.scalars().all() - return { - "hash": content_hash, - "sources": [{"node_id": s.node_id, "endpoint": s.endpoint} for s in sources], - } - - @router.get("/{group_id}/members") async def group_members( group_id: str, diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py index 7046c2f..3e996a7 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/users.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py @@ -41,7 +41,6 @@ from meshbay_hub.db.models import ( Node, Notification, RefreshToken, - SwarmSource, User, UserDevice, UserPreference, @@ -1379,14 +1378,13 @@ async def erase_account(db: AsyncSession, user: User, owned_groups: str = "refus the person it is about. Gone: credentials, email, node key, group memberships, notifications, refresh - tokens, node registrations, device keys, public-swarm sources. The username + tokens, node registrations, device keys. The username is released. Device keys go even though the desktop client keeps its private half: left behind, the key still belongs to this tombstone, so an account created later from the same installation is refused that device ("belongs to another - account"). Swarm sources are keyed by the *user* id and carry the node's - ip:port. + account"). Kept: the row itself, emptied, and the IP log that points at it. Those logs exist for one year to answer legal requests, and a log that cannot say whose @@ -1419,7 +1417,6 @@ async def erase_account(db: AsyncSession, user: User, owned_groups: str = "refus await db.execute(delete(RefreshToken).where(RefreshToken.user_id == user.id)) await db.execute(delete(Node).where(Node.user_id == user.id)) await db.execute(delete(UserDevice).where(UserDevice.user_id == user.id)) - await db.execute(delete(SwarmSource).where(SwarmSource.node_id == user.id)) await db.execute(delete(EmailVerification).where(EmailVerification.user_id == user.id)) # Links this account issued for a group it no longer owns; the ones for its # own groups went with them above. A used link keeps pointing at the diff --git a/packages/meshbay-hub/src/meshbay_hub/app.py b/packages/meshbay-hub/src/meshbay_hub/app.py index cca1e6b..29de118 100644 --- a/packages/meshbay-hub/src/meshbay_hub/app.py +++ b/packages/meshbay-hub/src/meshbay_hub/app.py @@ -21,7 +21,6 @@ from meshbay_hub.api.admin import router as admin_router from meshbay_hub.api.deps import set_admin_usernames from meshbay_hub.api.federation import router as federation_router from meshbay_hub.api.groups import router as groups_router -from meshbay_hub.api.groups import swarm_router from meshbay_hub.api.health import router as health_router from meshbay_hub.api.hub import router as hub_router from meshbay_hub.api.hub import set_config as hub_set_config @@ -199,7 +198,6 @@ def create_app(cfg: HubConfig | None = None) -> FastAPI: app.include_router(groups_router) app.include_router(invite_links_router) app.include_router(invite_links_redeem_router) - app.include_router(swarm_router) app.include_router(revocation_router) app.include_router(moderation_router) app.include_router(federation_router) diff --git a/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/e6f7a8b9c0d1_drop_swarm_sources.py b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/e6f7a8b9c0d1_drop_swarm_sources.py new file mode 100644 index 0000000..0e61390 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/e6f7a8b9c0d1_drop_swarm_sources.py @@ -0,0 +1,38 @@ +"""drop the public-content swarm table + +Nodes registered the hashes of their public groups here and nothing ever read +them back: the swarm was written and never used. + +Revision ID: e6f7a8b9c0d1 +Revises: c4d5e6f7a8b9 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "e6f7a8b9c0d1" +down_revision: str | Sequence[str] | None = "c4d5e6f7a8b9" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.drop_index("ix_swarm_hash", table_name="swarm_sources") + op.drop_table("swarm_sources") + + +def downgrade() -> None: + op.create_table( + "swarm_sources", + sa.Column("content_hash", sa.String(64), nullable=False), + sa.Column("node_id", sa.String(36), nullable=False), + sa.Column("endpoint", sa.String(128), nullable=False), + sa.Column("registered_at", sa.DateTime(timezone=True), nullable=False, + server_default=sa.func.now()), + sa.Column("last_seen", sa.DateTime(timezone=True), nullable=False, + server_default=sa.func.now()), + sa.PrimaryKeyConstraint("content_hash", "node_id"), + ) + op.create_index("ix_swarm_hash", "swarm_sources", ["content_hash"]) diff --git a/packages/meshbay-hub/src/meshbay_hub/db/models.py b/packages/meshbay-hub/src/meshbay_hub/db/models.py index 09eb437..288f1e7 100644 --- a/packages/meshbay-hub/src/meshbay_hub/db/models.py +++ b/packages/meshbay-hub/src/meshbay_hub/db/models.py @@ -266,22 +266,6 @@ class UserDevice(Base): __table_args__ = (Index("ix_user_devices_user", "user_id"),) -class SwarmSource(Base): - """ - Tracks which nodes can serve a given content hash (public swarm). - Hub maintains this for load-balanced public content delivery. - """ - __tablename__ = "swarm_sources" - - content_hash: Mapped[str] = mapped_column(String(64), primary_key=True) - node_id: Mapped[str] = mapped_column(String(36), primary_key=True) - endpoint: Mapped[str] = mapped_column(String(128), nullable=False) - registered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) - last_seen: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) - - __table_args__ = (Index("ix_swarm_hash", "content_hash"),) - - class Notification(Base): __tablename__ = "notifications" -- cgit v1.2.3