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
This commit is contained in:
2026-07-14 22:31:47 -07:00
committed by Hop
parent 988bd8ab38
commit 1427df4bdc
13 changed files with 282 additions and 61 deletions
+19
View File
@@ -24,6 +24,7 @@
"next-auth": "^5.0.0-beta.25", "next-auth": "^5.0.0-beta.25",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"tldts": "^7.4.8",
"uuid": "^11.1.0", "uuid": "^11.1.0",
"zod": "^4.3.5" "zod": "^4.3.5"
}, },
@@ -9737,6 +9738,24 @@
"node": ">=14.0.0" "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": { "node_modules/to-regex-range": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+1
View File
@@ -36,6 +36,7 @@
"next-auth": "^5.0.0-beta.25", "next-auth": "^5.0.0-beta.25",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"tldts": "^7.4.8",
"uuid": "^11.1.0", "uuid": "^11.1.0",
"zod": "^4.3.5" "zod": "^4.3.5"
}, },
+16 -1
View File
@@ -12,6 +12,7 @@ import { upsertSwarmNode } from '@/lib/swarm/registry';
import { buildAnnouncement } from '@/lib/swarm/discovery'; import { buildAnnouncement } from '@/lib/swarm/discovery';
import { verifySwarmRequest } from '@/lib/swarm/signature'; import { verifySwarmRequest } from '@/lib/swarm/signature';
import type { SwarmNodeInfo } from '@/lib/swarm/types'; import type { SwarmNodeInfo } from '@/lib/swarm/types';
import { getPublicSwarmDomain, isPublicSwarmDomain } from '@/lib/swarm/node-domain';
const optionalUrlSchema = z.preprocess((value) => { const optionalUrlSchema = z.preprocess((value) => {
if (typeof value !== 'string') { if (typeof value !== 'string') {
@@ -56,8 +57,22 @@ export async function POST(request: Request) {
const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN; 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 // Don't process announcements from ourselves
if (data.domain === ourDomain) { if (getPublicSwarmDomain(data.domain) === getPublicSwarmDomain(ourDomain)) {
return NextResponse.json( return NextResponse.json(
{ error: 'Cannot announce to self' }, { error: 'Cannot announce to self' },
{ status: 400 } { status: 400 }
+16 -1
View File
@@ -12,6 +12,7 @@ import { processGossip } from '@/lib/swarm/gossip';
import { markNodeSuccess } from '@/lib/swarm/registry'; import { markNodeSuccess } from '@/lib/swarm/registry';
import { verifySwarmRequest } from '@/lib/swarm/signature'; import { verifySwarmRequest } from '@/lib/swarm/signature';
import type { SwarmGossipPayload } from '@/lib/swarm/types'; import type { SwarmGossipPayload } from '@/lib/swarm/types';
import { getPublicSwarmDomain, isPublicSwarmDomain } from '@/lib/swarm/node-domain';
const handleSchema = z.object({ const handleSchema = z.object({
handle: z.string(), handle: z.string(),
@@ -62,8 +63,22 @@ export async function POST(request: Request) {
const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN; 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 // Don't process gossip from ourselves
if (data.sender === ourDomain) { if (getPublicSwarmDomain(data.sender) === getPublicSwarmDomain(ourDomain)) {
return NextResponse.json( return NextResponse.json(
{ error: 'Cannot gossip with self' }, { error: 'Cannot gossip with self' },
{ status: 400 } { status: 400 }
+8
View File
@@ -19,6 +19,7 @@ import {
discoverNode, discoverNode,
} from '@/lib/swarm/discovery'; } from '@/lib/swarm/discovery';
import { runGossipRound, gossipToNode } from '@/lib/swarm/gossip'; import { runGossipRound, gossipToNode } from '@/lib/swarm/gossip';
import { isPublicSwarmDomain } from '@/lib/swarm/node-domain';
/** /**
* GET /api/swarm/nodes * GET /api/swarm/nodes
@@ -75,6 +76,13 @@ export async function POST(request: Request) {
const body = await request.json(); const body = await request.json();
const { action, domain, priority } = actionSchema.parse(body); 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) { switch (action) {
case 'announce': { case 'announce': {
if (domain) { if (domain) {
+10
View File
@@ -13,6 +13,7 @@ import { runGossipRound } from '@/lib/swarm/gossip';
import { announceToSeeds } from '@/lib/swarm/discovery'; import { announceToSeeds } from '@/lib/swarm/discovery';
import { getSwarmStats } from '@/lib/swarm/registry'; import { getSwarmStats } from '@/lib/swarm/registry';
import { syncRemoteFollowsPosts } from '@/lib/background/remote-sync'; import { syncRemoteFollowsPosts } from '@/lib/background/remote-sync';
import { isPublicSwarmDomain } from '@/lib/swarm/node-domain';
const BOT_INTERVAL_MS = 60 * 1000; // 1 minute const BOT_INTERVAL_MS = 60 * 1000; // 1 minute
const GOSSIP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes 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) // Default origin for remote sync (can be overridden)
const syncOrigin = origin || process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:43821'; 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', '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`); 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 () => { setTimeout(async () => {
log('STARTUP', 'Starting background tasks...'); log('STARTUP', 'Starting background tasks...');
if (publicSwarmEnabled) {
// Announce to swarm on startup // Announce to swarm on startup
await announceToSwarm(); await announceToSwarm();
} else {
log('SWARM', 'Public swarm disabled: NEXT_PUBLIC_NODE_DOMAIN is not a public ICANN domain');
}
// Run initial bot check // Run initial bot check
await runBotTasks(); await runBotTasks();
@@ -140,11 +146,15 @@ export function startBackgroundTasks(origin?: string) {
// Schedule recurring tasks // Schedule recurring tasks
setInterval(runBotTasks, BOT_INTERVAL_MS); setInterval(runBotTasks, BOT_INTERVAL_MS);
if (publicSwarmEnabled) {
setInterval(runSwarmGossip, GOSSIP_INTERVAL_MS); setInterval(runSwarmGossip, GOSSIP_INTERVAL_MS);
}
setInterval(() => runRemoteSync(syncOrigin), REMOTE_SYNC_INTERVAL_MS); setInterval(() => runRemoteSync(syncOrigin), REMOTE_SYNC_INTERVAL_MS);
// First gossip after 30s (let announcement propagate) // First gossip after 30s (let announcement propagate)
if (publicSwarmEnabled) {
setTimeout(runSwarmGossip, 30 * 1000); setTimeout(runSwarmGossip, 30 * 1000);
}
log('STARTUP', 'Background tasks running'); log('STARTUP', 'Background tasks running');
}, STARTUP_DELAY_MS); }, STARTUP_DELAY_MS);
+38 -8
View File
@@ -8,6 +8,9 @@ import { db, nodes, users, posts } from '@/db';
import { eq, sql } from 'drizzle-orm'; import { eq, sql } from 'drizzle-orm';
import type { SwarmAnnouncement, SwarmNodeInfo, SwarmCapability } from './types'; import type { SwarmAnnouncement, SwarmNodeInfo, SwarmCapability } from './types';
import { upsertSwarmNode, getSeedNodes, markNodeSuccess, markNodeFailure } from './registry'; 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 { function normalizeOptionalUrl(value: string | null | undefined): string | undefined {
if (!value) { if (!value) {
@@ -77,6 +80,15 @@ export async function buildAnnouncement(): Promise<SwarmAnnouncement> {
* SECURITY: Signs the announcement with the node's private key * SECURITY: Signs the announcement with the node's private key
*/ */
export async function announceToNode(targetDomain: string): Promise<{ success: boolean; error?: string }> { 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 { try {
const announcement = await buildAnnouncement(); const announcement = await buildAnnouncement();
@@ -90,7 +102,7 @@ export async function announceToNode(targetDomain: string): Promise<{ success: b
signature, signature,
}; };
const baseUrl = targetDomain.startsWith('http') ? targetDomain : `https://${targetDomain}`; const baseUrl = `https://${publicTargetDomain}`;
const url = `${baseUrl}/api/swarm/announce`; const url = `${baseUrl}/api/swarm/announce`;
const response = await fetch(url, { const response = await fetch(url, {
@@ -104,20 +116,23 @@ export async function announceToNode(targetDomain: string): Promise<{ success: b
if (!response.ok) { if (!response.ok) {
const error = await response.text(); const error = await response.text();
await markNodeFailure(targetDomain); await markNodeFailure(publicTargetDomain);
return { success: false, error: `HTTP ${response.status}: ${error}` }; return { success: false, error: `HTTP ${response.status}: ${error}` };
} }
// The remote node should respond with their info // The remote node should respond with their info
const remoteInfo = await response.json() as SwarmNodeInfo; 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 // Add/update the remote node in our registry
await upsertSwarmNode(remoteInfo, 'direct'); await upsertSwarmNode(remoteInfo, 'direct');
await markNodeSuccess(targetDomain); await markNodeSuccess(publicTargetDomain);
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
await markNodeFailure(targetDomain); await markNodeFailure(publicTargetDomain);
return { return {
success: false, success: false,
error: error instanceof Error ? error.message : 'Unknown error' error: error instanceof Error ? error.message : 'Unknown error'
@@ -135,6 +150,10 @@ export async function announceToSeeds(): Promise<{
const seeds = await getSeedNodes(); const seeds = await getSeedNodes();
const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN; const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN;
if (!isPublicSwarmDomain(ourDomain)) {
return { successful: [], failed: [] };
}
// Don't announce to ourselves // Don't announce to ourselves
const targetSeeds = seeds.filter(s => s !== ourDomain); const targetSeeds = seeds.filter(s => s !== ourDomain);
@@ -157,8 +176,11 @@ export async function announceToSeeds(): Promise<{
* Fetch node info from a remote node * Fetch node info from a remote node
*/ */
export async function fetchNodeInfo(domain: string): Promise<SwarmNodeInfo | null> { export async function fetchNodeInfo(domain: string): Promise<SwarmNodeInfo | null> {
const publicDomain = getPublicSwarmDomain(domain);
if (!publicDomain) return null;
try { try {
const baseUrl = domain.startsWith('http') ? domain : `https://${domain}`; const baseUrl = `https://${publicDomain}`;
// Try the swarm endpoint first // Try the swarm endpoint first
let response = await fetch(`${baseUrl}/api/swarm/info`, { let response = await fetch(`${baseUrl}/api/swarm/info`, {
@@ -178,8 +200,11 @@ export async function fetchNodeInfo(domain: string): Promise<SwarmNodeInfo | nul
const data = await response.json(); const data = await response.json();
const returnedDomain = getPublicSwarmDomain(data.domain || publicDomain);
if (returnedDomain !== publicDomain) return null;
return { return {
domain: data.domain || domain, domain: returnedDomain,
name: data.name, name: data.name,
description: data.description, description: data.description,
logoUrl: data.logoUrl, logoUrl: data.logoUrl,
@@ -203,13 +228,18 @@ export async function discoverNode(
discoveredVia?: string discoveredVia?: string
): Promise<{ success: boolean; isNew: boolean; error?: string }> { ): Promise<{ success: boolean; isNew: boolean; error?: string }> {
const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN; 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 // Don't discover ourselves
if (domain === ourDomain) { if (publicDomain === getPublicSwarmDomain(ourDomain)) {
return { success: false, isNew: false, error: 'Cannot discover self' }; return { success: false, isNew: false, error: 'Cannot discover self' };
} }
const info = await fetchNodeInfo(domain); const info = await fetchNodeInfo(publicDomain);
if (!info) { if (!info) {
return { success: false, isNew: false, error: 'Could not fetch node info' }; return { success: false, isNew: false, error: 'Could not fetch node info' };
+34 -14
View File
@@ -20,6 +20,7 @@ import {
} from './registry'; } from './registry';
import { upsertHandleEntries } from '@/lib/federation/handles'; import { upsertHandleEntries } from '@/lib/federation/handles';
import { buildAnnouncement } from './discovery'; import { buildAnnouncement } from './discovery';
import { getPublicSwarmDomain, isPublicSwarmDomain } from './node-domain';
/** /**
* Build a gossip payload to send to another node * Build a gossip payload to send to another node
@@ -71,8 +72,8 @@ export async function buildGossipPayload(since?: string): Promise<SwarmGossipPay
return { return {
sender: ourDomain, sender: ourDomain,
nodes: [selfNode, ...nodes], nodes: isPublicSwarmDomain(ourDomain) ? [selfNode, ...nodes] : nodes,
handles, handles: handles.filter((handle) => isPublicSwarmDomain(handle.nodeDomain)),
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
since, since,
}; };
@@ -91,8 +92,9 @@ export async function processGossip(
// Process incoming handles // Process incoming handles
let handlesResult = { added: 0, updated: 0 }; let handlesResult = { added: 0, updated: 0 };
if (payload.handles && payload.handles.length > 0) { const publicHandles = payload.handles?.filter((handle) => isPublicSwarmDomain(handle.nodeDomain)) ?? [];
handlesResult = await upsertHandleEntries(payload.handles); if (publicHandles.length > 0) {
handlesResult = await upsertHandleEntries(publicHandles);
} }
// Build our response with nodes/handles to share back // Build our response with nodes/handles to share back
@@ -118,6 +120,19 @@ export async function gossipToNode(
since?: string since?: string
): Promise<SwarmSyncResult> { ): Promise<SwarmSyncResult> {
const startTime = Date.now(); 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 { try {
const payload = await buildGossipPayload(since); const payload = await buildGossipPayload(since);
@@ -132,7 +147,7 @@ export async function gossipToNode(
signature, signature,
}; };
const baseUrl = targetDomain.startsWith('http') ? targetDomain : `https://${targetDomain}`; const baseUrl = `https://${publicTargetDomain}`;
const url = `${baseUrl}/api/swarm/gossip`; const url = `${baseUrl}/api/swarm/gossip`;
const response = await fetch(url, { const response = await fetch(url, {
@@ -148,8 +163,8 @@ export async function gossipToNode(
if (!response.ok) { if (!response.ok) {
const error = `HTTP ${response.status}`; const error = `HTTP ${response.status}`;
await markNodeFailure(targetDomain); await markNodeFailure(publicTargetDomain);
await logSync(targetDomain, 'push', { await logSync(publicTargetDomain, 'push', {
success: false, success: false,
nodesReceived: 0, nodesReceived: 0,
nodesSent: payload.nodes.length, nodesSent: payload.nodes.length,
@@ -172,14 +187,15 @@ export async function gossipToNode(
const gossipResponse = await response.json() as SwarmGossipResponse; const gossipResponse = await response.json() as SwarmGossipResponse;
// Process the response (nodes and handles they sent back) // 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 }; let handlesResult = { added: 0, updated: 0 };
if (gossipResponse.handles && gossipResponse.handles.length > 0) { const publicHandles = gossipResponse.handles?.filter((handle) => isPublicSwarmDomain(handle.nodeDomain)) ?? [];
handlesResult = await upsertHandleEntries(gossipResponse.handles); if (publicHandles.length > 0) {
handlesResult = await upsertHandleEntries(publicHandles);
} }
await markNodeSuccess(targetDomain); await markNodeSuccess(publicTargetDomain);
const result: SwarmSyncResult = { const result: SwarmSyncResult = {
success: true, success: true,
@@ -190,13 +206,13 @@ export async function gossipToNode(
durationMs, durationMs,
}; };
await logSync(targetDomain, 'push', result); await logSync(publicTargetDomain, 'push', result);
return result; return result;
} catch (error) { } catch (error) {
const durationMs = Date.now() - startTime; const durationMs = Date.now() - startTime;
const errorMsg = error instanceof Error ? error.message : 'Unknown error'; const errorMsg = error instanceof Error ? error.message : 'Unknown error';
await markNodeFailure(targetDomain); await markNodeFailure(publicTargetDomain);
const result: SwarmSyncResult = { const result: SwarmSyncResult = {
success: false, success: false,
@@ -208,7 +224,7 @@ export async function gossipToNode(
durationMs, durationMs,
}; };
await logSync(targetDomain, 'push', result); await logSync(publicTargetDomain, 'push', result);
return result; return result;
} }
} }
@@ -222,6 +238,10 @@ export async function runGossipRound(): Promise<{
totalNodesReceived: number; totalNodesReceived: number;
totalHandlesReceived: 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 // Get random nodes to gossip with
const targets = await getNodesForGossip(SWARM_CONFIG.gossipFanout); const targets = await getNodesForGossip(SWARM_CONFIG.gossipFanout);
+2 -8
View File
@@ -1,14 +1,8 @@
import { db, swarmNodes } from '@/db'; import { db, swarmNodes } from '@/db';
import { and, eq, inArray } from 'drizzle-orm'; import { and, eq, inArray } from 'drizzle-orm';
import { normalizeNodeDomain } from './node-domain';
export function normalizeNodeDomain(domain: string): string { export { normalizeNodeDomain } from './node-domain';
return domain
.trim()
.toLowerCase()
.replace(/^https?:\/\//, '')
.replace(/\/.*$/, '')
.replace(/^@/, '');
}
export async function isNodeBlocked(domain: string | null | undefined): Promise<boolean> { export async function isNodeBlocked(domain: string | null | undefined): Promise<boolean> {
if (!db || !domain) return false; if (!db || !domain) return false;
+33
View File
@@ -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);
});
});
+54
View File
@@ -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;
}
+28 -17
View File
@@ -8,7 +8,7 @@ import { db, swarmNodes, swarmSeeds, swarmSyncLog } from '@/db';
import { eq, desc, and, gt, lt, sql } from 'drizzle-orm'; import { eq, desc, and, gt, lt, sql } from 'drizzle-orm';
import type { SwarmNodeInfo, SwarmCapability, SwarmSyncResult } from './types'; import type { SwarmNodeInfo, SwarmCapability, SwarmSyncResult } from './types';
import { SWARM_CONFIG, DEFAULT_SEED_NODES } 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 * Get or create a swarm node entry
@@ -21,11 +21,14 @@ export async function upsertSwarmNode(
return { isNew: false }; return { isNew: false };
} }
const existing = await db.query.swarmNodes.findFirst({ const normalizedDomain = getPublicSwarmDomain(node.domain);
where: { domain: normalizeNodeDomain(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; const capabilities = node.capabilities ? JSON.stringify(node.capabilities) : null;
@@ -86,8 +89,10 @@ export async function upsertSwarmNodes(
// Filter out our own domain // Filter out our own domain
const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN; const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN;
const filteredNodes = nodes.filter(n => n.domain !== ourDomain); const filteredNodes = nodes.filter(n => n.domain !== ourDomain);
const normalizedOurDomain = ourDomain ? normalizeNodeDomain(ourDomain) : null; const normalizedOurDomain = getPublicSwarmDomain(ourDomain);
const safeNodes = filteredNodes.filter(n => normalizeNodeDomain(n.domain) !== normalizedOurDomain); const safeNodes = filteredNodes.filter(n =>
isPublicSwarmDomain(n.domain) && getPublicSwarmDomain(n.domain) !== normalizedOurDomain
);
for (const node of safeNodes) { for (const node of safeNodes) {
const result = await upsertSwarmNode(node, discoveredVia); const result = await upsertSwarmNode(node, discoveredVia);
@@ -115,7 +120,7 @@ export async function getActiveSwarmNodes(limit = 100): Promise<SwarmNodeInfo[]>
limit, 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<SwarmNodeInfo[]>
limit: count, 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<SwarmNode
limit, limit,
}); });
return nodes.map(nodeToInfo); return nodes.filter((node) => isPublicSwarmDomain(node.domain)).map(nodeToInfo);
} }
/** /**
@@ -268,11 +273,11 @@ export async function getSeedNodes(): Promise<string[]> {
orderBy: (swarmSeeds) => [swarmSeeds.priority], orderBy: (swarmSeeds) => [swarmSeeds.priority],
}); });
if (seeds.length === 0) { const publicSeeds = seeds.map(s => s.domain).filter(isPublicSwarmDomain);
return [...DEFAULT_SEED_NODES];
}
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<string[]> {
export async function addSeedNode(domain: string, priority = 100): Promise<void> { export async function addSeedNode(domain: string, priority = 100): Promise<void> {
if (!db) return; 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) await db.insert(swarmSeeds)
.values({ domain, priority }) .values({ domain: normalizedDomain, priority })
.onConflictDoUpdate({ .onConflictDoUpdate({
target: swarmSeeds.domain, target: swarmSeeds.domain,
set: { priority, isEnabled: true }, set: { priority, isEnabled: true },
@@ -303,13 +313,14 @@ export async function getSwarmStats() {
} }
const allNodes = await db.query.swarmNodes.findMany(); 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 totalUsers = activeNodes.reduce((sum, n) => sum + (n.userCount || 0), 0);
const totalPosts = activeNodes.reduce((sum, n) => sum + (n.postCount || 0), 0); const totalPosts = activeNodes.reduce((sum, n) => sum + (n.postCount || 0), 0);
return { return {
totalNodes: allNodes.length, totalNodes: publicNodes.length,
activeNodes: activeNodes.length, activeNodes: activeNodes.length,
totalUsers, totalUsers,
totalPosts, totalPosts,
+19 -8
View File
@@ -10,7 +10,8 @@ import crypto from 'crypto';
import { db, users } from '@/db'; import { db, users } from '@/db';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { canonicalize } from '@/lib/crypto/user-signing'; 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 * 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<string | null> { export async function getNodePublicKey(domain: string): Promise<string | null> {
try { 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)) { if (await isNodeBlocked(normalizedDomain)) {
console.warn(`[Signature] Refusing public key fetch for blocked node ${normalizedDomain}`); console.warn(`[Signature] Refusing public key fetch for blocked node ${normalizedDomain}`);
return null; return null;
} }
// Check if we have a cached node info // Check if we have a cached node info
const protocol = normalizedDomain.includes('localhost') ? 'http' : 'https'; const response = await fetch(`https://${normalizedDomain}/api/node`, {
const response = await fetch(`${protocol}://${normalizedDomain}/api/node`, {
headers: { 'Accept': 'application/json' }, headers: { 'Accept': 'application/json' },
signal: AbortSignal.timeout(5000), signal: AbortSignal.timeout(5000),
}); });
@@ -96,7 +100,11 @@ export async function verifySwarmRequest(
signature: string, signature: string,
senderDomain: string senderDomain: string
): Promise<boolean> { ): Promise<boolean> {
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)) { if (await isNodeBlocked(normalizedDomain)) {
console.warn(`[Signature] Rejected blocked node ${normalizedDomain}`); console.warn(`[Signature] Rejected blocked node ${normalizedDomain}`);
return false; return false;
@@ -132,7 +140,11 @@ export async function verifyUserInteraction(
userDomain: string userDomain: string
): Promise<boolean> { ): Promise<boolean> {
try { 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)) { if (await isNodeBlocked(normalizedDomain)) {
console.warn(`[Signature] Rejected user interaction from blocked node ${normalizedDomain}`); console.warn(`[Signature] Rejected user interaction from blocked node ${normalizedDomain}`);
return false; return false;
@@ -150,8 +162,7 @@ export async function verifyUserInteraction(
publicKey = user.publicKey; publicKey = user.publicKey;
} else { } else {
// Fetch from remote node // Fetch from remote node
const protocol = normalizedDomain.includes('localhost') ? 'http' : 'https'; const response = await fetch(`https://${normalizedDomain}/api/users/${userHandle}`, {
const response = await fetch(`${protocol}://${normalizedDomain}/api/users/${userHandle}`, {
headers: { 'Accept': 'application/json' }, headers: { 'Accept': 'application/json' },
signal: AbortSignal.timeout(5000), signal: AbortSignal.timeout(5000),
}); });