From 1427df4bdc84d85666b7b050d6ce466c282af8ef Mon Sep 17 00:00:00 2001 From: cyph3rasi Date: Tue, 14 Jul 2026 22:31:47 -0700 Subject: [PATCH] Restrict public swarm participation and propagation to real public ICANN domains Hop-State: A_06FP8GM0PKJTMF45B7016J0 Hop-Proposal: R_06FP8GKFXXZG84ETVCPS5ZG Hop-Task: T_06FP8F45T3G4Z4ESW9NZQQR Hop-Attempt: AT_06FP8F45T3QS81M2ZM4SJ3G --- package-lock.json | 19 ++++++++++ package.json | 1 + src/app/api/swarm/announce/route.ts | 17 ++++++++- src/app/api/swarm/gossip/route.ts | 17 ++++++++- src/app/api/swarm/nodes/route.ts | 8 +++++ src/lib/background/scheduler.ts | 18 +++++++--- src/lib/swarm/discovery.ts | 46 +++++++++++++++++++----- src/lib/swarm/gossip.ts | 48 +++++++++++++++++-------- src/lib/swarm/node-blocklist.ts | 10 ++---- src/lib/swarm/node-domain.test.ts | 33 ++++++++++++++++++ src/lib/swarm/node-domain.ts | 54 +++++++++++++++++++++++++++++ src/lib/swarm/registry.ts | 45 +++++++++++++++--------- src/lib/swarm/signature.ts | 27 ++++++++++----- 13 files changed, 282 insertions(+), 61 deletions(-) create mode 100644 src/lib/swarm/node-domain.test.ts create mode 100644 src/lib/swarm/node-domain.ts diff --git a/package-lock.json b/package-lock.json index 1e37dc2..ebd9570 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "next-auth": "^5.0.0-beta.25", "react": "19.2.3", "react-dom": "19.2.3", + "tldts": "^7.4.8", "uuid": "^11.1.0", "zod": "^4.3.5" }, @@ -9737,6 +9738,24 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.4.8", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.8.tgz", + "integrity": "sha512-htwgN/8KRB3z3vnC0BOETVh2m499g5GmyTK9Wq5JBLX3FNz6tSBveAd+fQhzy9hkjif8vy2jwDMR1sGhLtZl2A==", + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.8" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.8", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.8.tgz", + "integrity": "sha512-c1P7u0EhACHj7lPy4MJm8iTFEU8+nB0LCtddH0fhP7noaVoXAqafMtOOeX+ulpuPBqnrRgRhw494RICT3mbhnw==", + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", diff --git a/package.json b/package.json index d7550f0..767a5ac 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "next-auth": "^5.0.0-beta.25", "react": "19.2.3", "react-dom": "19.2.3", + "tldts": "^7.4.8", "uuid": "^11.1.0", "zod": "^4.3.5" }, diff --git a/src/app/api/swarm/announce/route.ts b/src/app/api/swarm/announce/route.ts index c516886..f79629c 100644 --- a/src/app/api/swarm/announce/route.ts +++ b/src/app/api/swarm/announce/route.ts @@ -12,6 +12,7 @@ import { upsertSwarmNode } from '@/lib/swarm/registry'; import { buildAnnouncement } from '@/lib/swarm/discovery'; import { verifySwarmRequest } from '@/lib/swarm/signature'; import type { SwarmNodeInfo } from '@/lib/swarm/types'; +import { getPublicSwarmDomain, isPublicSwarmDomain } from '@/lib/swarm/node-domain'; const optionalUrlSchema = z.preprocess((value) => { if (typeof value !== 'string') { @@ -55,9 +56,23 @@ export async function POST(request: Request) { const data = signedAnnouncementSchema.parse(body); const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN; + + if (!isPublicSwarmDomain(ourDomain)) { + return NextResponse.json( + { error: 'This node is not configured for public swarm participation' }, + { status: 503 } + ); + } + + if (!isPublicSwarmDomain(data.domain)) { + return NextResponse.json( + { error: 'Swarm nodes must use a public ICANN domain' }, + { status: 400 } + ); + } // Don't process announcements from ourselves - if (data.domain === ourDomain) { + if (getPublicSwarmDomain(data.domain) === getPublicSwarmDomain(ourDomain)) { return NextResponse.json( { error: 'Cannot announce to self' }, { status: 400 } diff --git a/src/app/api/swarm/gossip/route.ts b/src/app/api/swarm/gossip/route.ts index 2dee0a8..78c2322 100644 --- a/src/app/api/swarm/gossip/route.ts +++ b/src/app/api/swarm/gossip/route.ts @@ -12,6 +12,7 @@ import { processGossip } from '@/lib/swarm/gossip'; import { markNodeSuccess } from '@/lib/swarm/registry'; import { verifySwarmRequest } from '@/lib/swarm/signature'; import type { SwarmGossipPayload } from '@/lib/swarm/types'; +import { getPublicSwarmDomain, isPublicSwarmDomain } from '@/lib/swarm/node-domain'; const handleSchema = z.object({ handle: z.string(), @@ -61,9 +62,23 @@ export async function POST(request: Request) { const data = signedGossipSchema.parse(body); const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN; + + if (!isPublicSwarmDomain(ourDomain)) { + return NextResponse.json( + { error: 'This node is not configured for public swarm participation' }, + { status: 503 } + ); + } + + if (!isPublicSwarmDomain(data.sender)) { + return NextResponse.json( + { error: 'Swarm nodes must use a public ICANN domain' }, + { status: 400 } + ); + } // Don't process gossip from ourselves - if (data.sender === ourDomain) { + if (getPublicSwarmDomain(data.sender) === getPublicSwarmDomain(ourDomain)) { return NextResponse.json( { error: 'Cannot gossip with self' }, { status: 400 } diff --git a/src/app/api/swarm/nodes/route.ts b/src/app/api/swarm/nodes/route.ts index 8bd1dc2..1f482ff 100644 --- a/src/app/api/swarm/nodes/route.ts +++ b/src/app/api/swarm/nodes/route.ts @@ -19,6 +19,7 @@ import { discoverNode, } from '@/lib/swarm/discovery'; import { runGossipRound, gossipToNode } from '@/lib/swarm/gossip'; +import { isPublicSwarmDomain } from '@/lib/swarm/node-domain'; /** * GET /api/swarm/nodes @@ -75,6 +76,13 @@ export async function POST(request: Request) { const body = await request.json(); const { action, domain, priority } = actionSchema.parse(body); + if (domain && !isPublicSwarmDomain(domain)) { + return NextResponse.json( + { error: 'Swarm nodes must use a public ICANN domain' }, + { status: 400 } + ); + } + switch (action) { case 'announce': { if (domain) { diff --git a/src/lib/background/scheduler.ts b/src/lib/background/scheduler.ts index 30b1efb..83bd2ef 100644 --- a/src/lib/background/scheduler.ts +++ b/src/lib/background/scheduler.ts @@ -13,6 +13,7 @@ import { runGossipRound } from '@/lib/swarm/gossip'; import { announceToSeeds } from '@/lib/swarm/discovery'; import { getSwarmStats } from '@/lib/swarm/registry'; import { syncRemoteFollowsPosts } from '@/lib/background/remote-sync'; +import { isPublicSwarmDomain } from '@/lib/swarm/node-domain'; const BOT_INTERVAL_MS = 60 * 1000; // 1 minute const GOSSIP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes @@ -121,6 +122,7 @@ export function startBackgroundTasks(origin?: string) { // Default origin for remote sync (can be overridden) const syncOrigin = origin || process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:43821'; + const publicSwarmEnabled = isPublicSwarmDomain(process.env.NEXT_PUBLIC_NODE_DOMAIN); log('STARTUP', 'Background task scheduler starting...'); log('STARTUP', `Bot interval: ${BOT_INTERVAL_MS / 1000}s, Gossip interval: ${GOSSIP_INTERVAL_MS / 1000}s, Remote sync interval: ${REMOTE_SYNC_INTERVAL_MS / 1000}s`); @@ -129,8 +131,12 @@ export function startBackgroundTasks(origin?: string) { setTimeout(async () => { log('STARTUP', 'Starting background tasks...'); - // Announce to swarm on startup - await announceToSwarm(); + if (publicSwarmEnabled) { + // Announce to swarm on startup + await announceToSwarm(); + } else { + log('SWARM', 'Public swarm disabled: NEXT_PUBLIC_NODE_DOMAIN is not a public ICANN domain'); + } // Run initial bot check await runBotTasks(); @@ -140,11 +146,15 @@ export function startBackgroundTasks(origin?: string) { // Schedule recurring tasks setInterval(runBotTasks, BOT_INTERVAL_MS); - setInterval(runSwarmGossip, GOSSIP_INTERVAL_MS); + if (publicSwarmEnabled) { + setInterval(runSwarmGossip, GOSSIP_INTERVAL_MS); + } setInterval(() => runRemoteSync(syncOrigin), REMOTE_SYNC_INTERVAL_MS); // First gossip after 30s (let announcement propagate) - setTimeout(runSwarmGossip, 30 * 1000); + if (publicSwarmEnabled) { + setTimeout(runSwarmGossip, 30 * 1000); + } log('STARTUP', 'Background tasks running'); }, STARTUP_DELAY_MS); diff --git a/src/lib/swarm/discovery.ts b/src/lib/swarm/discovery.ts index 53a8fec..95b6b58 100644 --- a/src/lib/swarm/discovery.ts +++ b/src/lib/swarm/discovery.ts @@ -8,6 +8,9 @@ import { db, nodes, users, posts } from '@/db'; import { eq, sql } from 'drizzle-orm'; import type { SwarmAnnouncement, SwarmNodeInfo, SwarmCapability } from './types'; import { upsertSwarmNode, getSeedNodes, markNodeSuccess, markNodeFailure } from './registry'; +import { getPublicSwarmDomain, isPublicSwarmDomain } from './node-domain'; + +const PUBLIC_SWARM_DOMAIN_ERROR = 'Public swarm participation requires a real ICANN domain'; function normalizeOptionalUrl(value: string | null | undefined): string | undefined { if (!value) { @@ -77,6 +80,15 @@ export async function buildAnnouncement(): Promise { * SECURITY: Signs the announcement with the node's private key */ export async function announceToNode(targetDomain: string): Promise<{ success: boolean; error?: string }> { + if (!isPublicSwarmDomain(process.env.NEXT_PUBLIC_NODE_DOMAIN)) { + return { success: false, error: PUBLIC_SWARM_DOMAIN_ERROR }; + } + + const publicTargetDomain = getPublicSwarmDomain(targetDomain); + if (!publicTargetDomain) { + return { success: false, error: `Invalid public swarm domain: ${targetDomain}` }; + } + try { const announcement = await buildAnnouncement(); @@ -90,7 +102,7 @@ export async function announceToNode(targetDomain: string): Promise<{ success: b signature, }; - const baseUrl = targetDomain.startsWith('http') ? targetDomain : `https://${targetDomain}`; + const baseUrl = `https://${publicTargetDomain}`; const url = `${baseUrl}/api/swarm/announce`; const response = await fetch(url, { @@ -104,20 +116,23 @@ export async function announceToNode(targetDomain: string): Promise<{ success: b if (!response.ok) { const error = await response.text(); - await markNodeFailure(targetDomain); + await markNodeFailure(publicTargetDomain); return { success: false, error: `HTTP ${response.status}: ${error}` }; } // The remote node should respond with their info const remoteInfo = await response.json() as SwarmNodeInfo; + if (getPublicSwarmDomain(remoteInfo.domain) !== publicTargetDomain) { + return { success: false, error: 'Remote node returned a different domain identity' }; + } // Add/update the remote node in our registry await upsertSwarmNode(remoteInfo, 'direct'); - await markNodeSuccess(targetDomain); + await markNodeSuccess(publicTargetDomain); return { success: true }; } catch (error) { - await markNodeFailure(targetDomain); + await markNodeFailure(publicTargetDomain); return { success: false, error: error instanceof Error ? error.message : 'Unknown error' @@ -134,6 +149,10 @@ export async function announceToSeeds(): Promise<{ }> { const seeds = await getSeedNodes(); const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN; + + if (!isPublicSwarmDomain(ourDomain)) { + return { successful: [], failed: [] }; + } // Don't announce to ourselves const targetSeeds = seeds.filter(s => s !== ourDomain); @@ -157,8 +176,11 @@ export async function announceToSeeds(): Promise<{ * Fetch node info from a remote node */ export async function fetchNodeInfo(domain: string): Promise { + const publicDomain = getPublicSwarmDomain(domain); + if (!publicDomain) return null; + try { - const baseUrl = domain.startsWith('http') ? domain : `https://${domain}`; + const baseUrl = `https://${publicDomain}`; // Try the swarm endpoint first let response = await fetch(`${baseUrl}/api/swarm/info`, { @@ -178,8 +200,11 @@ export async function fetchNodeInfo(domain: string): Promise { const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN; + const publicDomain = getPublicSwarmDomain(domain); + + if (!publicDomain) { + return { success: false, isNew: false, error: `Invalid public swarm domain: ${domain}` }; + } // Don't discover ourselves - if (domain === ourDomain) { + if (publicDomain === getPublicSwarmDomain(ourDomain)) { return { success: false, isNew: false, error: 'Cannot discover self' }; } - const info = await fetchNodeInfo(domain); + const info = await fetchNodeInfo(publicDomain); if (!info) { return { success: false, isNew: false, error: 'Could not fetch node info' }; diff --git a/src/lib/swarm/gossip.ts b/src/lib/swarm/gossip.ts index 876e27c..6e02826 100644 --- a/src/lib/swarm/gossip.ts +++ b/src/lib/swarm/gossip.ts @@ -20,6 +20,7 @@ import { } from './registry'; import { upsertHandleEntries } from '@/lib/federation/handles'; import { buildAnnouncement } from './discovery'; +import { getPublicSwarmDomain, isPublicSwarmDomain } from './node-domain'; /** * Build a gossip payload to send to another node @@ -71,8 +72,8 @@ export async function buildGossipPayload(since?: string): Promise isPublicSwarmDomain(handle.nodeDomain)), timestamp: new Date().toISOString(), since, }; @@ -91,8 +92,9 @@ export async function processGossip( // Process incoming handles let handlesResult = { added: 0, updated: 0 }; - if (payload.handles && payload.handles.length > 0) { - handlesResult = await upsertHandleEntries(payload.handles); + const publicHandles = payload.handles?.filter((handle) => isPublicSwarmDomain(handle.nodeDomain)) ?? []; + if (publicHandles.length > 0) { + handlesResult = await upsertHandleEntries(publicHandles); } // Build our response with nodes/handles to share back @@ -118,6 +120,19 @@ export async function gossipToNode( since?: string ): Promise { const startTime = Date.now(); + const publicTargetDomain = getPublicSwarmDomain(targetDomain); + + if (!isPublicSwarmDomain(process.env.NEXT_PUBLIC_NODE_DOMAIN) || !publicTargetDomain) { + return { + success: false, + nodesReceived: 0, + nodesSent: 0, + handlesReceived: 0, + handlesSent: 0, + error: 'Public swarm participation requires real ICANN domains', + durationMs: Date.now() - startTime, + }; + } try { const payload = await buildGossipPayload(since); @@ -132,7 +147,7 @@ export async function gossipToNode( signature, }; - const baseUrl = targetDomain.startsWith('http') ? targetDomain : `https://${targetDomain}`; + const baseUrl = `https://${publicTargetDomain}`; const url = `${baseUrl}/api/swarm/gossip`; const response = await fetch(url, { @@ -148,8 +163,8 @@ export async function gossipToNode( if (!response.ok) { const error = `HTTP ${response.status}`; - await markNodeFailure(targetDomain); - await logSync(targetDomain, 'push', { + await markNodeFailure(publicTargetDomain); + await logSync(publicTargetDomain, 'push', { success: false, nodesReceived: 0, nodesSent: payload.nodes.length, @@ -172,14 +187,15 @@ export async function gossipToNode( const gossipResponse = await response.json() as SwarmGossipResponse; // Process the response (nodes and handles they sent back) - const nodeResult = await upsertSwarmNodes(gossipResponse.nodes, targetDomain); + const nodeResult = await upsertSwarmNodes(gossipResponse.nodes, publicTargetDomain); let handlesResult = { added: 0, updated: 0 }; - if (gossipResponse.handles && gossipResponse.handles.length > 0) { - handlesResult = await upsertHandleEntries(gossipResponse.handles); + const publicHandles = gossipResponse.handles?.filter((handle) => isPublicSwarmDomain(handle.nodeDomain)) ?? []; + if (publicHandles.length > 0) { + handlesResult = await upsertHandleEntries(publicHandles); } - await markNodeSuccess(targetDomain); + await markNodeSuccess(publicTargetDomain); const result: SwarmSyncResult = { success: true, @@ -190,13 +206,13 @@ export async function gossipToNode( durationMs, }; - await logSync(targetDomain, 'push', result); + await logSync(publicTargetDomain, 'push', result); return result; } catch (error) { const durationMs = Date.now() - startTime; const errorMsg = error instanceof Error ? error.message : 'Unknown error'; - await markNodeFailure(targetDomain); + await markNodeFailure(publicTargetDomain); const result: SwarmSyncResult = { success: false, @@ -208,7 +224,7 @@ export async function gossipToNode( durationMs, }; - await logSync(targetDomain, 'push', result); + await logSync(publicTargetDomain, 'push', result); return result; } } @@ -222,6 +238,10 @@ export async function runGossipRound(): Promise<{ totalNodesReceived: number; totalHandlesReceived: number; }> { + if (!isPublicSwarmDomain(process.env.NEXT_PUBLIC_NODE_DOMAIN)) { + return { contacted: 0, successful: 0, totalNodesReceived: 0, totalHandlesReceived: 0 }; + } + // Get random nodes to gossip with const targets = await getNodesForGossip(SWARM_CONFIG.gossipFanout); diff --git a/src/lib/swarm/node-blocklist.ts b/src/lib/swarm/node-blocklist.ts index 4acdf96..2fa8a15 100644 --- a/src/lib/swarm/node-blocklist.ts +++ b/src/lib/swarm/node-blocklist.ts @@ -1,14 +1,8 @@ import { db, swarmNodes } from '@/db'; import { and, eq, inArray } from 'drizzle-orm'; +import { normalizeNodeDomain } from './node-domain'; -export function normalizeNodeDomain(domain: string): string { - return domain - .trim() - .toLowerCase() - .replace(/^https?:\/\//, '') - .replace(/\/.*$/, '') - .replace(/^@/, ''); -} +export { normalizeNodeDomain } from './node-domain'; export async function isNodeBlocked(domain: string | null | undefined): Promise { if (!db || !domain) return false; diff --git a/src/lib/swarm/node-domain.test.ts b/src/lib/swarm/node-domain.test.ts new file mode 100644 index 0000000..15dd8a2 --- /dev/null +++ b/src/lib/swarm/node-domain.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { getPublicSwarmDomain, isPublicSwarmDomain } from './node-domain'; + +describe('public swarm domains', () => { + it.each([ + ['synapsis.social', 'synapsis.social'], + ['https://Node.Synapsis.Social/', 'node.synapsis.social'], + ['node.synapsis.social:8443', 'node.synapsis.social:8443'], + ['social.example.co.uk', 'social.example.co.uk'], + ])('accepts a public ICANN domain: %s', (input, expected) => { + expect(getPublicSwarmDomain(input)).toBe(expected); + }); + + it.each([ + 'localhost', + 'localhost:43821', + '127.0.0.1', + '192.168.1.20:43821', + '[::1]:43821', + 'synapsis.local', + 'synapsis.test', + 'synapsis.invalid', + 'synapsis.internal', + 'example.com', + 'node.example.com', + 'node.onion', + 'home.arpa', + 'not a domain', + ])('rejects a local, reserved, or non-public domain: %s', (input) => { + expect(isPublicSwarmDomain(input)).toBe(false); + }); +}); + diff --git a/src/lib/swarm/node-domain.ts b/src/lib/swarm/node-domain.ts new file mode 100644 index 0000000..1eba667 --- /dev/null +++ b/src/lib/swarm/node-domain.ts @@ -0,0 +1,54 @@ +import { parse } from 'tldts'; + +export function normalizeNodeDomain(domain: string): string { + return domain + .trim() + .toLowerCase() + .replace(/^https?:\/\//, '') + .replace(/\/.*$/, '') + .replace(/^@/, ''); +} + +/** + * Return the canonical host for a publicly addressable swarm node. + * + * The swarm is a public network, so local development hosts, IP addresses, + * special-use names, and made-up TLDs must never enter the node registry. + * Ports are retained because a public hostname may intentionally expose + * Synapsis on a non-standard port. + */ +export function getPublicSwarmDomain(value: string | null | undefined): string | null { + if (!value) return null; + + const normalized = normalizeNodeDomain(value); + if (!normalized) return null; + + try { + const url = new URL(`https://${normalized}`); + if (url.username || url.password) return null; + + const hostname = url.hostname.toLowerCase().replace(/\.$/, ''); + const result = parse(hostname, { + allowPrivateDomains: false, + detectSpecialUse: true, + }); + + if ( + !result.domain || + result.isIcann !== true || + result.isIp || + result.isSpecialUse + ) { + return null; + } + + return url.port ? `${hostname}:${url.port}` : hostname; + } catch { + return null; + } +} + +export function isPublicSwarmDomain(value: string | null | undefined): boolean { + return getPublicSwarmDomain(value) !== null; +} + diff --git a/src/lib/swarm/registry.ts b/src/lib/swarm/registry.ts index e0cf176..4304b4f 100644 --- a/src/lib/swarm/registry.ts +++ b/src/lib/swarm/registry.ts @@ -8,7 +8,7 @@ import { db, swarmNodes, swarmSeeds, swarmSyncLog } from '@/db'; import { eq, desc, and, gt, lt, sql } from 'drizzle-orm'; import type { SwarmNodeInfo, SwarmCapability, SwarmSyncResult } from './types'; import { SWARM_CONFIG, DEFAULT_SEED_NODES } from './types'; -import { normalizeNodeDomain } from './node-blocklist'; +import { getPublicSwarmDomain, isPublicSwarmDomain } from './node-domain'; /** * Get or create a swarm node entry @@ -21,11 +21,14 @@ export async function upsertSwarmNode( return { isNew: false }; } - const existing = await db.query.swarmNodes.findFirst({ - where: { domain: normalizeNodeDomain(node.domain) }, - }); + const normalizedDomain = getPublicSwarmDomain(node.domain); + if (!normalizedDomain) { + throw new Error(`Swarm nodes must use a public ICANN domain: ${node.domain}`); + } - const normalizedDomain = normalizeNodeDomain(node.domain); + const existing = await db.query.swarmNodes.findFirst({ + where: { domain: normalizedDomain }, + }); const capabilities = node.capabilities ? JSON.stringify(node.capabilities) : null; @@ -86,8 +89,10 @@ export async function upsertSwarmNodes( // Filter out our own domain const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN; const filteredNodes = nodes.filter(n => n.domain !== ourDomain); - const normalizedOurDomain = ourDomain ? normalizeNodeDomain(ourDomain) : null; - const safeNodes = filteredNodes.filter(n => normalizeNodeDomain(n.domain) !== normalizedOurDomain); + const normalizedOurDomain = getPublicSwarmDomain(ourDomain); + const safeNodes = filteredNodes.filter(n => + isPublicSwarmDomain(n.domain) && getPublicSwarmDomain(n.domain) !== normalizedOurDomain + ); for (const node of safeNodes) { const result = await upsertSwarmNode(node, discoveredVia); @@ -115,7 +120,7 @@ export async function getActiveSwarmNodes(limit = 100): Promise limit, }); - return nodes.map(nodeToInfo); + return nodes.filter((node) => isPublicSwarmDomain(node.domain)).map(nodeToInfo); } /** @@ -133,7 +138,7 @@ export async function getNodesForGossip(count: number): Promise limit: count, }); - return nodes.map(nodeToInfo); + return nodes.filter((node) => isPublicSwarmDomain(node.domain)).map(nodeToInfo); } /** @@ -150,7 +155,7 @@ export async function getNodesSince(since: Date, limit = 100): Promise isPublicSwarmDomain(node.domain)).map(nodeToInfo); } /** @@ -268,11 +273,11 @@ export async function getSeedNodes(): Promise { orderBy: (swarmSeeds) => [swarmSeeds.priority], }); - if (seeds.length === 0) { - return [...DEFAULT_SEED_NODES]; - } + const publicSeeds = seeds.map(s => s.domain).filter(isPublicSwarmDomain); - return seeds.map(s => s.domain); + return publicSeeds.length > 0 + ? publicSeeds + : DEFAULT_SEED_NODES.filter(isPublicSwarmDomain); } /** @@ -281,8 +286,13 @@ export async function getSeedNodes(): Promise { export async function addSeedNode(domain: string, priority = 100): Promise { if (!db) return; + const normalizedDomain = getPublicSwarmDomain(domain); + if (!normalizedDomain) { + throw new Error(`Seed nodes must use a public ICANN domain: ${domain}`); + } + await db.insert(swarmSeeds) - .values({ domain, priority }) + .values({ domain: normalizedDomain, priority }) .onConflictDoUpdate({ target: swarmSeeds.domain, set: { priority, isEnabled: true }, @@ -303,13 +313,14 @@ export async function getSwarmStats() { } const allNodes = await db.query.swarmNodes.findMany(); - const activeNodes = allNodes.filter(n => n.isActive); + const publicNodes = allNodes.filter(n => isPublicSwarmDomain(n.domain)); + const activeNodes = publicNodes.filter(n => n.isActive); const totalUsers = activeNodes.reduce((sum, n) => sum + (n.userCount || 0), 0); const totalPosts = activeNodes.reduce((sum, n) => sum + (n.postCount || 0), 0); return { - totalNodes: allNodes.length, + totalNodes: publicNodes.length, activeNodes: activeNodes.length, totalUsers, totalPosts, diff --git a/src/lib/swarm/signature.ts b/src/lib/swarm/signature.ts index 9f85adb..b43cd66 100644 --- a/src/lib/swarm/signature.ts +++ b/src/lib/swarm/signature.ts @@ -10,7 +10,8 @@ import crypto from 'crypto'; import { db, users } from '@/db'; import { eq } from 'drizzle-orm'; import { canonicalize } from '@/lib/crypto/user-signing'; -import { isNodeBlocked, normalizeNodeDomain } from './node-blocklist'; +import { isNodeBlocked } from './node-blocklist'; +import { getPublicSwarmDomain } from './node-domain'; /** * Sign a payload with the node's private key @@ -57,15 +58,18 @@ export function verifySignature(payload: any, signature: string, publicKey: stri */ export async function getNodePublicKey(domain: string): Promise { try { - const normalizedDomain = normalizeNodeDomain(domain); + const normalizedDomain = getPublicSwarmDomain(domain); + if (!normalizedDomain) { + console.warn(`[Signature] Refusing public key fetch for non-public node ${domain}`); + return null; + } if (await isNodeBlocked(normalizedDomain)) { console.warn(`[Signature] Refusing public key fetch for blocked node ${normalizedDomain}`); return null; } // Check if we have a cached node info - const protocol = normalizedDomain.includes('localhost') ? 'http' : 'https'; - const response = await fetch(`${protocol}://${normalizedDomain}/api/node`, { + const response = await fetch(`https://${normalizedDomain}/api/node`, { headers: { 'Accept': 'application/json' }, signal: AbortSignal.timeout(5000), }); @@ -96,7 +100,11 @@ export async function verifySwarmRequest( signature: string, senderDomain: string ): Promise { - const normalizedDomain = normalizeNodeDomain(senderDomain); + const normalizedDomain = getPublicSwarmDomain(senderDomain); + if (!normalizedDomain) { + console.warn(`[Signature] Rejected non-public swarm node ${senderDomain}`); + return false; + } if (await isNodeBlocked(normalizedDomain)) { console.warn(`[Signature] Rejected blocked node ${normalizedDomain}`); return false; @@ -132,7 +140,11 @@ export async function verifyUserInteraction( userDomain: string ): Promise { try { - const normalizedDomain = normalizeNodeDomain(userDomain); + const normalizedDomain = getPublicSwarmDomain(userDomain); + if (!normalizedDomain) { + console.warn(`[Signature] Rejected user interaction from non-public node ${userDomain}`); + return false; + } if (await isNodeBlocked(normalizedDomain)) { console.warn(`[Signature] Rejected user interaction from blocked node ${normalizedDomain}`); return false; @@ -150,8 +162,7 @@ export async function verifyUserInteraction( publicKey = user.publicKey; } else { // Fetch from remote node - const protocol = normalizedDomain.includes('localhost') ? 'http' : 'https'; - const response = await fetch(`${protocol}://${normalizedDomain}/api/users/${userHandle}`, { + const response = await fetch(`https://${normalizedDomain}/api/users/${userHandle}`, { headers: { 'Accept': 'application/json' }, signal: AbortSignal.timeout(5000), });