diff --git a/bootstrap/package.json b/bootstrap/package.json index 22bc501..94b890c 100644 --- a/bootstrap/package.json +++ b/bootstrap/package.json @@ -1,6 +1,6 @@ { "name": "dap-bootstrap", - "version": "1.0.0", + "version": "0.5.0", "type": "module", "description": "DAP standalone bootstrap peer exchange server", "main": "server.mjs", diff --git a/bootstrap/server.mjs b/bootstrap/server.mjs index 6a0a161..8cd5686 100644 --- a/bootstrap/server.mjs +++ b/bootstrap/server.mjs @@ -17,7 +17,7 @@ import crypto from "node:crypto"; import { createRequire } from "node:module"; const __require = createRequire(import.meta.url); -const pkgVersion = __require("../package.json").version; +const pkgVersion = __require("./package.json").version; const PROTOCOL_VERSION = pkgVersion.split(".").slice(0, 2).join("."); const PORT = parseInt(process.env.PEER_PORT ?? "8099"); diff --git a/docker/test-runner.mjs b/docker/test-runner.mjs index 13a06fc..fa42a4d 100644 --- a/docker/test-runner.mjs +++ b/docker/test-runner.mjs @@ -5,7 +5,7 @@ * NODE_ROLE=server — starts peer server, waits for a message, exits 0 on success * NODE_ROLE=client — waits for server, sends one message, exits 0 on success */ -import { loadOrCreateIdentity, getPublicIPv6 } from "./dist/identity.js"; +import { loadOrCreateIdentity } from "./dist/identity.js"; import { initDb } from "./dist/peer-db.js"; import { startPeerServer, getInbox } from "./dist/peer-server.js"; import { sendP2PMessage } from "./dist/peer-client.js"; @@ -28,13 +28,7 @@ mkdirSync(DATA_DIR, { recursive: true }); const identity = loadOrCreateIdentity(DATA_DIR); initDb(DATA_DIR); -const publicIpv6 = getPublicIPv6(); console.log(`[${ROLE}] Identity: ${identity.agentId.slice(0, 8)}...`); -if (publicIpv6) { - console.log(`[${ROLE}] IPv6: ${publicIpv6}`); -} else { - console.warn(`[${ROLE}] WARNING: no globally-routable IPv6 found — using container IPv6`); -} // ── SERVER ────────────────────────────────────────────────────────────────── if (ROLE === "server") { diff --git a/openclaw.plugin.json b/openclaw.plugin.json index b1afddc..dc645e6 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -27,6 +27,14 @@ "default": 8098, "description": "Local port for the QUIC/UDP transport (optional fast transport)" }, + "advertise_address": { + "type": "string", + "description": "Public IP or DNS name to advertise for the QUIC/UDP transport when the listener is bound to a different local interface" + }, + "advertise_port": { + "type": "integer", + "description": "Public UDP port to advertise for the QUIC/UDP transport when it differs from the local listener port" + }, "data_dir": { "type": "string", "description": "Directory to store identity and peer data (default: ~/.openclaw/dap)" @@ -51,6 +59,14 @@ "label": "QUIC Transport Port (UDP)", "placeholder": "8098" }, + "advertise_address": { + "label": "Advertised QUIC Address", + "placeholder": "vpn.example.com" + }, + "advertise_port": { + "label": "Advertised QUIC Port", + "placeholder": "4433" + }, "data_dir": { "label": "Data Directory" }, diff --git a/src/identity.ts b/src/identity.ts index 2271fe5..05f0f8b 100644 --- a/src/identity.ts +++ b/src/identity.ts @@ -9,7 +9,6 @@ import { sha256 } from "@noble/hashes/sha256" import { createHash } from "node:crypto" import * as fs from "fs" import * as path from "path" -import * as os from "os" import { Identity, AwRequestHeaders, AwResponseHeaders } from "./types" // Protocol version for HTTP signatures and domain separators. @@ -334,49 +333,3 @@ export function verifyHttpResponseHeaders( return ok ? { ok: true } : { ok: false, error: "Invalid X-AgentWorld-Signature" } } -// ── Utility ───────────────────────────────────────────────────────────────── - -/** - * Returns true if addr is a globally-routable unicast IPv6 address (2000::/3). - */ -export function isGlobalUnicastIPv6(addr: string): boolean { - if (!addr || !addr.includes(":")) return false - const clean = addr.replace(/^::ffff:/i, "").toLowerCase() - if (clean === "::1") return false - if (clean.startsWith("fe80:")) return false - if (clean.startsWith("fc") || clean.startsWith("fd")) return false - const first = parseInt(clean.split(":")[0].padStart(4, "0"), 16) - return first >= 0x2000 && first <= 0x3fff -} - -/** - * Returns the first globally-routable public IPv6 address on any interface. - */ -export function getPublicIPv6(): string | null { - const ifaces = os.networkInterfaces() - for (const iface of Object.values(ifaces)) { - if (!iface) continue - for (const info of iface) { - if (info.family === "IPv6" && !info.internal && isGlobalUnicastIPv6(info.address)) { - return info.address - } - } - } - return null -} - -/** - * Returns the first non-loopback, non-link-local IPv6 address on any interface. - */ -export function getActualIpv6(): string | null { - const ifaces = os.networkInterfaces() - for (const iface of Object.values(ifaces)) { - if (!iface) continue - for (const info of iface) { - if (info.family === "IPv6" && !info.internal && !info.address.startsWith("fe80:")) { - return info.address - } - } - } - return null -} diff --git a/src/index.ts b/src/index.ts index ebf3421..a6c21a0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,9 +7,9 @@ import * as os from "os" import * as path from "path" import { execSync } from "child_process" -import { loadOrCreateIdentity, deriveDidKey } from "./identity" +import { loadOrCreateIdentity, deriveDidKey, verifyHttpResponseHeaders } from "./identity" import { initDb, listPeers, getPeer, flushDb, getPeerIds, getEndpointAddress, setTofuTtl, findPeersByCapability, removePeer } from "./peer-db" -import { startPeerServer, stopPeerServer, setSelfMeta, handleUdpMessage, addWorldMembers, removeWorld, clearWorldMembers } from "./peer-server" +import { startPeerServer, stopPeerServer, setSelfMeta, handleUdpMessage, addWorldMembers, setWorldMembers, removeWorld, clearWorldMembers } from "./peer-server" import { sendP2PMessage, pingPeer, broadcastLeave, SendOptions, getPeerPingInfo } from "./peer-client" import { upsertDiscoveredPeer } from "./peer-db" import { buildChannel, wireInboundToGateway, CHANNEL_CONFIG_SCHEMA } from "./channel" @@ -71,7 +71,7 @@ let _transportManager: TransportManager | null = null let _quicTransport: UDPTransport | null = null // Track joined worlds for periodic member refresh -const _joinedWorlds = new Map() +const _joinedWorlds = new Map() const _worldMembersByWorld = new Map>() const _worldScopedPeerWorlds = new Map>() const _worldRefreshFailures = new Map() @@ -192,10 +192,25 @@ async function refreshWorldMembers(): Promise { recordWorldRefreshFailure(worldId) continue } - const body = await resp.json() as { members?: Array<{ agentId: string; alias?: string; endpoints?: Endpoint[] }> } + const bodyText = await resp.text() + const verification = verifyHttpResponseHeaders( + Object.fromEntries(Array.from(resp.headers.entries()).map(([key, value]) => [key, value])), + resp.status, + bodyText, + info.publicKey + ) + if (!verification.ok) { + recordWorldRefreshFailure(worldId) + continue + } + + const body = JSON.parse(bodyText) as { members?: Array<{ agentId: string; alias?: string; endpoints?: Endpoint[] }> } const memberList = body.members ?? [] syncWorldMembers(worldId, memberList) - addWorldMembers(worldId, memberList.map(m => m.agentId).filter(id => id !== identity!.agentId)) + setWorldMembers( + worldId, + [info.agentId, ...memberList.map(m => m.agentId).filter(id => id !== identity!.agentId)] + ) _worldRefreshFailures.delete(worldId) } catch { recordWorldRefreshFailure(worldId) @@ -259,7 +274,12 @@ export default function register(api: any) { _transportManager.register(_quicTransport) const quicPort = cfg.quic_port ?? 8098 - const activeTransport = await _transportManager.start(identity, { dataDir, quicPort }) + const activeTransport = await _transportManager.start(identity, { + dataDir, + quicPort, + advertiseAddress: cfg.advertise_address, + advertisePort: cfg.advertise_port, + }) if (activeTransport) { console.log(`[p2p] Active transport: ${activeTransport.id} -> ${activeTransport.address}`) @@ -673,6 +693,7 @@ export default function register(api: any) { let targetAddr: string let targetPort: number = peerPort let worldAgentId: string | undefined + let worldPublicKey: string | undefined if (params.address) { const parsedAddress = parseDirectPeerAddress(params.address, peerPort) @@ -686,7 +707,11 @@ export default function register(api: any) { if (typeof ping.data?.agentId !== "string" || ping.data.agentId.length === 0) { return { content: [{ type: "text", text: `World at ${params.address} did not provide a stable agent ID.` }], isError: true } } + if (typeof ping.data?.publicKey !== "string" || ping.data.publicKey.length === 0) { + return { content: [{ type: "text", text: `World at ${params.address} did not provide a verifiable public key.` }], isError: true } + } worldAgentId = ping.data.agentId + worldPublicKey = ping.data.publicKey } else { const worlds = findPeersByCapability(`world:${params.world_id}`) if (!worlds.length) { @@ -699,6 +724,11 @@ export default function register(api: any) { targetAddr = world.endpoints[0].address targetPort = world.endpoints[0].port ?? peerPort worldAgentId = world.agentId + worldPublicKey = getPeer(worldAgentId)?.publicKey ?? "" + } + + if (!worldPublicKey) { + return { content: [{ type: "text", text: "World public key is unavailable; cannot verify signed membership refreshes." }], isError: true } } const myEndpoints: Endpoint[] = _agentMeta.endpoints ?? [] @@ -719,7 +749,7 @@ export default function register(api: any) { ? (result.data.manifest as { name: string }).name : worldId - upsertDiscoveredPeer(worldAgentId!, "", { + upsertDiscoveredPeer(worldAgentId!, worldPublicKey, { alias: worldName, capabilities: [`world:${worldId}`], endpoints: [{ transport: "tcp", address: targetAddr, port: targetPort, priority: 1, ttl: 3600 }], @@ -731,7 +761,7 @@ export default function register(api: any) { addWorldMembers(worldId, [worldAgentId!, ...joinMembers.map(m => m.agentId).filter(id => id !== identity!.agentId)]) // Track this world for periodic member refresh - _joinedWorlds.set(worldId, { agentId: worldAgentId!, address: targetAddr, port: targetPort }) + _joinedWorlds.set(worldId, { agentId: worldAgentId!, address: targetAddr, port: targetPort, publicKey: worldPublicKey }) _worldRefreshFailures.delete(worldId) if (!_memberRefreshTimer) { _memberRefreshTimer = setInterval(refreshWorldMembers, MEMBER_REFRESH_INTERVAL_MS) diff --git a/src/peer-server.ts b/src/peer-server.ts index 08534e5..1a7d75e 100644 --- a/src/peer-server.ts +++ b/src/peer-server.ts @@ -38,6 +38,11 @@ export function addWorldMembers(worldId: string, memberIds: string[]): void { for (const id of memberIds) set.add(id) } +/** Replace the member set for a world — revokes access for any IDs not in the new list. */ +export function setWorldMembers(worldId: string, memberIds: string[]): void { + _worldMembers.set(worldId, new Set(memberIds)) +} + export function removeWorld(worldId: string): void { _worldMembers.delete(worldId) } @@ -124,7 +129,12 @@ export async function startPeerServer(port: number = 8099, opts?: PeerServerOpti return payload }) - server.get("/peer/ping", async () => ({ ok: true, ts: Date.now() })) + server.get("/peer/ping", async () => ({ + ok: true, + ts: Date.now(), + agentId: _selfMeta.agentId ?? _identity?.agentId, + publicKey: _selfMeta.publicKey ?? _identity?.publicKey, + })) server.post("/peer/message", async (req, reply) => { const raw = req.body as any diff --git a/src/transport-quic.ts b/src/transport-quic.ts index 514de89..9fbfe37 100644 --- a/src/transport-quic.ts +++ b/src/transport-quic.ts @@ -4,25 +4,20 @@ * IMPORTANT: This is a plain UDP datagram transport, NOT a real QUIC * implementation. It provides: * - Unencrypted, unreliable UDP delivery (no retransmission, no ordering) - * - STUN-assisted NAT traversal for public endpoint discovery * - Messages >MTU (~1400 bytes) may be silently dropped * + * The advertised public endpoint is determined by explicit configuration + * (ADVERTISE_ADDRESS / ADVERTISE_PORT env vars or plugin config), not by + * automatic NIC scanning or STUN. This is intentional: in the world-scoped + * architecture, reachable addresses are a deployment concern. + * * Security relies entirely on the application-layer Ed25519 signatures. * When Node.js native QUIC (node:quic, Node 24+) becomes stable, this * transport should be upgraded to use it for transport-layer encryption. */ import * as dgram from "node:dgram" -import * as net from "node:net" import { Transport, TransportId, TransportEndpoint } from "./transport" import { Identity } from "./types" -import { getActualIpv6, getPublicIPv6 } from "./identity" - -/** Well-known public STUN servers for NAT traversal. */ -const STUN_SERVERS = [ - "stun.l.google.com:19302", - "stun1.l.google.com:19302", - "stun.cloudflare.com:3478", -] /** Check if Node.js native QUIC is available (node:quic, Node 24+). */ function isNativeQuicAvailable(): boolean { @@ -34,103 +29,6 @@ function isNativeQuicAvailable(): boolean { } } -/** - * Perform a simple STUN binding request to discover our public IP:port. - * Returns null if STUN fails (e.g., no internet, firewall). - */ -async function stunDiscover( - socket: dgram.Socket, - stunServer: string, - timeoutMs: number = 5000 -): Promise<{ address: string; port: number } | null> { - const [host, portStr] = stunServer.split(":") - const port = parseInt(portStr, 10) - - return new Promise((resolve) => { - const timer = setTimeout(() => resolve(null), timeoutMs) - - // STUN Binding Request (RFC 5389 minimal) - // Magic cookie: 0x2112A442 - const txId = Buffer.alloc(12) - for (let i = 0; i < 12; i++) txId[i] = Math.floor(Math.random() * 256) - - const msg = Buffer.alloc(20) - msg.writeUInt16BE(0x0001, 0) // Binding Request - msg.writeUInt16BE(0x0000, 2) // Message Length - msg.writeUInt32BE(0x2112a442, 4) // Magic Cookie - txId.copy(msg, 8) - - const onMessage = (data: Buffer) => { - clearTimeout(timer) - socket.removeListener("message", onMessage) - - // Parse XOR-MAPPED-ADDRESS from STUN response - const parsed = parseStunResponse(data) - resolve(parsed) - } - - socket.on("message", onMessage) - - // Resolve STUN server hostname before sending - require("node:dns").lookup(host, { family: 4 }, (err: Error | null, address: string) => { - if (err) { - clearTimeout(timer) - socket.removeListener("message", onMessage) - resolve(null) - return - } - socket.send(msg, 0, msg.length, port, address) - }) - }) -} - -/** Parse a STUN Binding Response to extract the mapped address. */ -function parseStunResponse(data: Buffer): { address: string; port: number } | null { - if (data.length < 20) return null - - const msgType = data.readUInt16BE(0) - if (msgType !== 0x0101) return null // Not a Binding Success Response - - const msgLen = data.readUInt16BE(2) - let offset = 20 - - while (offset < 20 + msgLen) { - const attrType = data.readUInt16BE(offset) - const attrLen = data.readUInt16BE(offset + 2) - offset += 4 - - // XOR-MAPPED-ADDRESS (0x0020) or MAPPED-ADDRESS (0x0001) - if (attrType === 0x0020 && attrLen >= 8) { - const family = data[offset + 1] - if (family === 0x01) { // IPv4 - const xPort = data.readUInt16BE(offset + 2) ^ 0x2112 - const xAddr = data.readUInt32BE(offset + 4) ^ 0x2112a442 - const a = (xAddr >>> 24) & 0xff - const b = (xAddr >>> 16) & 0xff - const c = (xAddr >>> 8) & 0xff - const d = xAddr & 0xff - return { address: `${a}.${b}.${c}.${d}`, port: xPort } - } - } else if (attrType === 0x0001 && attrLen >= 8) { - const family = data[offset + 1] - if (family === 0x01) { // IPv4 - const port = data.readUInt16BE(offset + 2) - const a = data[offset + 4] - const b = data[offset + 5] - const c = data[offset + 6] - const d = data[offset + 7] - return { address: `${a}.${b}.${c}.${d}`, port } - } - } - - offset += attrLen - // Pad to 4-byte boundary - if (attrLen % 4 !== 0) offset += 4 - (attrLen % 4) - } - - return null -} - export class UDPTransport implements Transport { readonly id: TransportId = "quic" private _address: string = "" @@ -152,6 +50,14 @@ export class UDPTransport implements Transport { async start(identity: Identity, opts?: Record): Promise { const port = (opts?.quicPort as number) ?? 8098 const testMode = (opts?.testMode as boolean) ?? false + const advertiseAddress = (opts?.advertiseAddress as string | undefined) ?? process.env.ADVERTISE_ADDRESS + const advertisePort = (opts?.advertisePort as number | undefined) + ?? (process.env.ADVERTISE_PORT ? parseInt(process.env.ADVERTISE_PORT, 10) : undefined) + + if (!testMode && !advertiseAddress) { + console.warn("[transport:quic] Disabled: no advertised public endpoint configured (set ADVERTISE_ADDRESS / advertise_address)") + return false + } // Check for native QUIC support first this._useNativeQuic = isNativeQuicAvailable() @@ -182,67 +88,21 @@ export class UDPTransport implements Transport { } }) - // Check for native public IPv6 first — globally routable, no STUN needed. - // When universal IPv6 is available this becomes the primary path. - if (!testMode) { - const publicIpv6 = getPublicIPv6() - if (publicIpv6) { - this._address = `[${publicIpv6}]:${actualPort}` - this._publicEndpoint = { address: publicIpv6, port: actualPort } - console.log(`[transport:quic] Native public IPv6: ${this._address} (STUN skipped)`) - } + // Use explicit advertise address if configured + if (!testMode && advertiseAddress) { + const effPort = advertisePort ?? actualPort + const isIpv6 = advertiseAddress.includes(":") && !advertiseAddress.includes(".") + this._address = isIpv6 ? `[${advertiseAddress}]:${effPort}` : `${advertiseAddress}:${effPort}` + this._publicEndpoint = { address: advertiseAddress, port: effPort } + console.log(`[transport:quic] Advertised endpoint: ${this._address}`) } - // Try STUN discovery for IPv4 public endpoint only if no public IPv6 found. - // We also create a companion IPv4 UDP socket on the same port so the - // STUN-mapped port matches the port we are actually listening on. - if (!testMode && !this._address) { - let stunSocket: dgram.Socket | null = null - try { - stunSocket = dgram.createSocket("udp4") - await new Promise((resolve, reject) => { - stunSocket!.on("error", reject) - stunSocket!.bind(actualPort, () => { - stunSocket!.removeListener("error", reject) - resolve() - }) - }) - } catch { - // Port already taken on IPv4 — fall back to ephemeral port - try { stunSocket?.close() } catch { /* ignore */ } - stunSocket = dgram.createSocket("udp4") - await new Promise((resolve, reject) => { - stunSocket!.on("error", reject) - stunSocket!.bind(0, () => { - stunSocket!.removeListener("error", reject) - resolve() - }) - }).catch(() => { stunSocket = null }) - } - - if (stunSocket) { - for (const server of STUN_SERVERS) { - try { - const result = await stunDiscover(stunSocket, server, 3000) - if (result) { - this._publicEndpoint = result - // Use STUN-discovered public IP but always advertise the actual - // listening port (in case STUN socket was ephemeral). - this._address = `${result.address}:${actualPort}` - console.log(`[transport:quic] Public endpoint: ${this._address} (via ${server})`) - break - } - } catch { /* try next */ } - } - try { stunSocket.close() } catch { /* ignore */ } - } - } - - // Fallback to local address if STUN failed + // Tests can run without a public endpoint; production cannot. if (!this._address) { - const localIp = getActualIpv6() ?? "::1" - this._address = `[${localIp}]:${actualPort}` - console.log(`[transport:quic] Local endpoint: ${this._address} (STUN unavailable)`) + this._address = `[::1]:${actualPort}` + if (!testMode) { + console.log(`[transport:quic] Local endpoint: ${this._address} (set ADVERTISE_ADDRESS for public reachability)`) + } } this._active = true @@ -288,8 +148,8 @@ export class UDPTransport implements Transport { getEndpoint(): TransportEndpoint { return { transport: "quic", - address: this._address, - port: this._port, + address: this._publicEndpoint?.address ?? this._address, + port: this._publicEndpoint?.port ?? this._port, priority: 0, ttl: 3600, } @@ -314,4 +174,4 @@ function parseHostPort(addr: string): { host: string; port: number } { throw new Error(`Invalid address format: ${addr}`) } -export { parseHostPort, isNativeQuicAvailable, stunDiscover, parseStunResponse } +export { parseHostPort, isNativeQuicAvailable } diff --git a/src/transport.ts b/src/transport.ts index e1ce4f0..bbc5363 100644 --- a/src/transport.ts +++ b/src/transport.ts @@ -98,16 +98,22 @@ export class TransportManager { } getEndpoints(): Endpoint[] { - return Array.from(this._transports.values()).map((t) => { - const ep = t.getEndpoint() - return { - transport: ep.transport as Endpoint["transport"], - address: ep.address, - port: ep.port, - priority: ep.priority, - ttl: ep.ttl, + const endpoints: Endpoint[] = [] + for (const t of this._transports.values()) { + try { + const ep = t.getEndpoint() + endpoints.push({ + transport: ep.transport as Endpoint["transport"], + address: ep.address, + port: ep.port, + priority: ep.priority, + ttl: ep.ttl, + }) + } catch { + continue } - }) + } + return endpoints } resolveTransport(address: string): Transport | null { diff --git a/src/types.ts b/src/types.ts index b3beafd..c008f88 100644 --- a/src/types.ts +++ b/src/types.ts @@ -74,6 +74,10 @@ export interface PluginConfig { quic_port?: number data_dir?: string tofu_ttl_days?: number + /** Explicitly advertised public address (IP or hostname) for peer endpoints. */ + advertise_address?: string + /** Explicitly advertised public port for QUIC transport. */ + advertise_port?: number } // ── AgentWorld HTTP signing headers ──────────────────────────────────────────── diff --git a/test-server.mjs b/test-server.mjs index e6609e4..9eb705e 100644 --- a/test-server.mjs +++ b/test-server.mjs @@ -2,7 +2,7 @@ * Local test: run a standalone P2P peer server (Node B). * Usage: NODE_ROLE=server P2P_PORT=8099 node test-server.mjs */ -import { loadOrCreateIdentity, getActualIpv6 } from "./dist/identity.js"; +import { loadOrCreateIdentity } from "./dist/identity.js"; import { initDb } from "./dist/peer-db.js"; import { startPeerServer, getInbox } from "./dist/peer-server.js"; import { mkdirSync } from "fs"; @@ -16,11 +16,7 @@ mkdirSync(DATA_DIR, { recursive: true }); const identity = loadOrCreateIdentity(DATA_DIR); initDb(DATA_DIR); -const actualIpv6 = getActualIpv6(); -if (actualIpv6) identity.yggIpv6 = actualIpv6; - console.log(`[node-b] Agent ID : ${identity.agentId.slice(0, 8)}...`); -console.log(`[node-b] IPv6 : ${identity.yggIpv6}`); console.log(`[node-b] Starting peer server on [::]:${PORT} (test mode)...`); await startPeerServer(PORT, { testMode: true }); diff --git a/test/agentid-identity.test.mjs b/test/agentid-identity.test.mjs index 46cc8da..07f79eb 100644 --- a/test/agentid-identity.test.mjs +++ b/test/agentid-identity.test.mjs @@ -1,6 +1,6 @@ import { describe, it } from "node:test" import assert from "node:assert/strict" -import { agentIdFromPublicKey, deriveDidKey, generateIdentity, loadOrCreateIdentity, isGlobalUnicastIPv6, getPublicIPv6 } from "../dist/identity.js" +import { agentIdFromPublicKey, deriveDidKey, generateIdentity, loadOrCreateIdentity } from "../dist/identity.js" import * as fs from "fs" import * as path from "path" import * as os from "os" @@ -61,40 +61,6 @@ describe("generateIdentity", () => { }) }) -describe("isGlobalUnicastIPv6", () => { - const accept = [ - "2001:db8::1", - "2600:1f18:1234:5678::1", - "2a00:1450:4001:81a::200e", - "3fff::1", - ] - const reject = [ - "::1", // loopback - "fe80::1", // link-local - "fd00::1", // ULA - "fc00::1", // ULA - "200:697f:bda:1e8e:706a:6c5e:630b:51d", // Yggdrasil - "201:cbd5:ca3:993a:f985:84e5:9735:cd1e", // Yggdrasil - "::ffff:192.168.1.1", // IPv4-mapped - "1.2.3.4", // not IPv6 - ] - for (const addr of accept) { - it(`accepts ${addr}`, () => assert.ok(isGlobalUnicastIPv6(addr))) - } - for (const addr of reject) { - it(`rejects ${addr}`, () => assert.ok(!isGlobalUnicastIPv6(addr))) - } -}) - -describe("getPublicIPv6", () => { - it("returns null or a globally-routable IPv6 string", () => { - const result = getPublicIPv6() - if (result !== null) { - assert.ok(isGlobalUnicastIPv6(result), `expected global unicast, got ${result}`) - } - }) -}) - describe("loadOrCreateIdentity", () => { it("adds agentId to a legacy identity file on load", () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "dap-test-")) diff --git a/test/index-lifecycle.test.mjs b/test/index-lifecycle.test.mjs index 93e2b2a..23df3cf 100644 --- a/test/index-lifecycle.test.mjs +++ b/test/index-lifecycle.test.mjs @@ -23,7 +23,7 @@ function clearModuleCache() { function createHarness({ firstRun = false, - pingInfo = { ok: true, data: { agentId: "aw:sha256:world-host" } }, + pingInfo = { ok: true, data: { agentId: "aw:sha256:world-host", publicKey: "d29ybGQtcHVibGljLWtleQ==" } }, joinResponse = { ok: true, data: { worldId: "arena", manifest: { name: "Arena" }, members: [] } }, fetchImpl = async () => ({ ok: true, status: 200, json: async () => ({ members: [] }) }), } = {}) { @@ -268,7 +268,7 @@ describe("plugin lifecycle", () => { it("stores direct world joins under the world agent ID", async () => { const worldAgentId = "aw:sha256:world-host" const harness = createHarness({ - pingInfo: { ok: true, data: { agentId: worldAgentId } }, + pingInfo: { ok: true, data: { agentId: worldAgentId, publicKey: "d29ybGQtcHVibGljLWtleQ==" } }, joinResponse: { ok: true, data: { @@ -311,7 +311,7 @@ describe("plugin lifecycle", () => { const worldAgentId = "aw:sha256:world-host" let refreshCalls = 0 const harness = createHarness({ - pingInfo: { ok: true, data: { agentId: worldAgentId } }, + pingInfo: { ok: true, data: { agentId: worldAgentId, publicKey: "d29ybGQtcHVibGljLWtleQ==" } }, joinResponse: { ok: true, data: { diff --git a/test/transport-quic.test.mjs b/test/transport-quic.test.mjs index bf7c9d0..a46a7b6 100644 --- a/test/transport-quic.test.mjs +++ b/test/transport-quic.test.mjs @@ -1,6 +1,6 @@ import { describe, it } from "node:test" import assert from "node:assert/strict" -import { parseHostPort, isNativeQuicAvailable, parseStunResponse } from "../dist/transport-quic.js" +import { parseHostPort, isNativeQuicAvailable } from "../dist/transport-quic.js" import { UDPTransport } from "../dist/transport-quic.js" describe("parseHostPort", () => { @@ -40,42 +40,6 @@ describe("isNativeQuicAvailable", () => { }) }) -describe("parseStunResponse", () => { - it("returns null for too-short buffer", () => { - const buf = Buffer.alloc(10) - assert.equal(parseStunResponse(buf), null) - }) - - it("returns null for non-binding-success response", () => { - const buf = Buffer.alloc(20) - buf.writeUInt16BE(0x0100, 0) // Not a Binding Success Response - assert.equal(parseStunResponse(buf), null) - }) - - it("parses MAPPED-ADDRESS attribute", () => { - // Build a minimal STUN Binding Success Response with MAPPED-ADDRESS - const buf = Buffer.alloc(32) - buf.writeUInt16BE(0x0101, 0) // Binding Success Response - buf.writeUInt16BE(12, 2) // Message Length - // Skip magic cookie + transaction ID (bytes 4-19) - // MAPPED-ADDRESS attribute at offset 20 - buf.writeUInt16BE(0x0001, 20) // Attribute type: MAPPED-ADDRESS - buf.writeUInt16BE(8, 22) // Attribute length - buf[24] = 0x00 // Padding - buf[25] = 0x01 // Family: IPv4 - buf.writeUInt16BE(12345, 26) // Port - buf[28] = 203 // IP: 203.0.113.1 - buf[29] = 0 - buf[30] = 113 - buf[31] = 1 - - const result = parseStunResponse(buf) - assert.ok(result) - assert.equal(result.address, "203.0.113.1") - assert.equal(result.port, 12345) - }) -}) - describe("UDPTransport", () => { it("has id 'quic'", () => { const qt = new UDPTransport() @@ -97,7 +61,7 @@ describe("UDPTransport", () => { it("can start and stop in test mode", async () => { const qt = new UDPTransport() - const id = { agentId: "test", publicKey: "", privateKey: "", cgaIpv6: "", yggIpv6: "" } + const id = { agentId: "test", publicKey: "", privateKey: "" } const ok = await qt.start(id, { testMode: true, quicPort: 0 }) assert.equal(ok, true) assert.equal(qt.isActive(), true) @@ -106,6 +70,33 @@ describe("UDPTransport", () => { assert.equal(qt.isActive(), false) }) + it("uses ADVERTISE_ADDRESS when provided", async () => { + const qt = new UDPTransport() + const id = { agentId: "test", publicKey: "", privateKey: "" } + const ok = await qt.start(id, { + quicPort: 0, + advertiseAddress: "203.0.113.1", + advertisePort: 9000, + }) + assert.equal(ok, true) + assert.equal(qt.address, "203.0.113.1:9000") + assert.deepEqual(qt.publicEndpoint, { address: "203.0.113.1", port: 9000 }) + await qt.stop() + }) + + it("uses bracketed format for IPv6 advertise address", async () => { + const qt = new UDPTransport() + const id = { agentId: "test", publicKey: "", privateKey: "" } + const ok = await qt.start(id, { + quicPort: 0, + advertiseAddress: "2001:db8::1", + advertisePort: 8098, + }) + assert.equal(ok, true) + assert.equal(qt.address, "[2001:db8::1]:8098") + await qt.stop() + }) + it("registers message handlers", () => { const qt = new UDPTransport() let called = false