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:
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<SwarmAnnouncement> {
|
||||
* 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<SwarmNodeInfo | null> {
|
||||
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<SwarmNodeInfo | nul
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
const returnedDomain = getPublicSwarmDomain(data.domain || publicDomain);
|
||||
if (returnedDomain !== publicDomain) return null;
|
||||
|
||||
return {
|
||||
domain: data.domain || domain,
|
||||
domain: returnedDomain,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
logoUrl: data.logoUrl,
|
||||
@@ -203,13 +228,18 @@ export async function discoverNode(
|
||||
discoveredVia?: string
|
||||
): Promise<{ success: boolean; isNew: boolean; error?: string }> {
|
||||
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' };
|
||||
|
||||
+34
-14
@@ -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<SwarmGossipPay
|
||||
|
||||
return {
|
||||
sender: ourDomain,
|
||||
nodes: [selfNode, ...nodes],
|
||||
handles,
|
||||
nodes: isPublicSwarmDomain(ourDomain) ? [selfNode, ...nodes] : nodes,
|
||||
handles: handles.filter((handle) => 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<SwarmSyncResult> {
|
||||
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);
|
||||
|
||||
|
||||
@@ -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<boolean> {
|
||||
if (!db || !domain) return false;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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<SwarmNodeInfo[]>
|
||||
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,
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
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],
|
||||
});
|
||||
|
||||
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<string[]> {
|
||||
export async function addSeedNode(domain: string, priority = 100): Promise<void> {
|
||||
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,
|
||||
|
||||
@@ -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<string | null> {
|
||||
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<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)) {
|
||||
console.warn(`[Signature] Rejected blocked node ${normalizedDomain}`);
|
||||
return false;
|
||||
@@ -132,7 +140,11 @@ export async function verifyUserInteraction(
|
||||
userDomain: string
|
||||
): Promise<boolean> {
|
||||
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),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user