diff --git a/changelog.md b/changelog.md index 4ad7e97..b9b6702 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,11 @@ # Changelog +## 2026-03-20 - 4.14.0 - feat(quic) +add QUIC stability test coverage and bridge logging for hub and edge + +- adds a long-running QUIC stability test with periodic echo probes and disconnect detection +- enables prefixed bridge logging for RemoteIngressHub and RemoteIngressEdge to improve runtime diagnostics + ## 2026-03-20 - 4.13.2 - fix(remoteingress-core) preserve reconnected edge entries during disconnect cleanup diff --git a/test/test.quic-stability.node.ts b/test/test.quic-stability.node.ts new file mode 100644 index 0000000..eb046c2 --- /dev/null +++ b/test/test.quic-stability.node.ts @@ -0,0 +1,228 @@ +import { expect, tap } from '@push.rocks/tapbundle'; +import * as net from 'net'; +import * as crypto from 'crypto'; +import { RemoteIngressHub, RemoteIngressEdge } from '../ts/index.js'; + +// --------------------------------------------------------------------------- +// Helpers (same patterns as test.quic.node.ts) +// --------------------------------------------------------------------------- + +async function findFreePorts(count: number): Promise { + const servers: net.Server[] = []; + const ports: number[] = []; + for (let i = 0; i < count; i++) { + const server = net.createServer(); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + ports.push((server.address() as net.AddressInfo).port); + servers.push(server); + } + await Promise.all(servers.map((s) => new Promise((resolve) => s.close(() => resolve())))); + return ports; +} + +type TrackingServer = net.Server & { destroyAll: () => void }; + +function startEchoServer(port: number, host: string): Promise { + return new Promise((resolve, reject) => { + const connections = new Set(); + const server = net.createServer((socket) => { + connections.add(socket); + socket.on('close', () => connections.delete(socket)); + let proxyHeaderParsed = false; + let pendingBuf = Buffer.alloc(0); + socket.on('data', (data: Buffer) => { + if (!proxyHeaderParsed) { + pendingBuf = Buffer.concat([pendingBuf, data]); + const idx = pendingBuf.indexOf('\r\n'); + if (idx !== -1) { + proxyHeaderParsed = true; + const remainder = pendingBuf.subarray(idx + 2); + if (remainder.length > 0) socket.write(remainder); + } + return; + } + socket.write(data); + }); + socket.on('error', () => {}); + }) as TrackingServer; + server.destroyAll = () => { + for (const conn of connections) conn.destroy(); + connections.clear(); + }; + server.on('error', reject); + server.listen(port, host, () => resolve(server)); + }); +} + +async function forceCloseServer(server: TrackingServer): Promise { + server.destroyAll(); + await new Promise((resolve) => server.close(() => resolve())); +} + +function sendAndReceive(port: number, data: Buffer, timeoutMs = 30000): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let totalReceived = 0; + const expectedLength = data.length; + let settled = false; + + const client = net.createConnection({ host: '127.0.0.1', port }, () => { + client.write(data); + client.end(); + }); + + const timer = setTimeout(() => { + if (!settled) { + settled = true; + client.destroy(); + reject(new Error(`Timeout after ${timeoutMs}ms — received ${totalReceived}/${expectedLength} bytes`)); + } + }, timeoutMs); + + client.on('data', (chunk: Buffer) => { + chunks.push(chunk); + totalReceived += chunk.length; + if (totalReceived >= expectedLength && !settled) { + settled = true; + clearTimeout(timer); + client.destroy(); + resolve(Buffer.concat(chunks)); + } + }); + + client.on('end', () => { + if (!settled) { + settled = true; + clearTimeout(timer); + resolve(Buffer.concat(chunks)); + } + }); + + client.on('error', (err) => { + if (!settled) { + settled = true; + clearTimeout(timer); + reject(err); + } + }); + }); +} + +function sha256(buf: Buffer): string { + return crypto.createHash('sha256').update(buf).digest('hex'); +} + +// --------------------------------------------------------------------------- +// QUIC Long-Running Stability Test — 2 minutes +// --------------------------------------------------------------------------- + +let hub: RemoteIngressHub; +let edge: RemoteIngressEdge; +let echoServer: TrackingServer; +let hubPort: number; +let edgePort: number; +let disconnectCount = 0; + +tap.test('QUIC stability setup: start echo server and QUIC tunnel', async () => { + [hubPort, edgePort] = await findFreePorts(2); + + echoServer = await startEchoServer(edgePort, '127.0.0.2'); + + hub = new RemoteIngressHub(); + edge = new RemoteIngressEdge(); + + await hub.start({ + tunnelPort: hubPort, + targetHost: '127.0.0.2', + }); + + await hub.updateAllowedEdges([ + { id: 'test-edge', secret: 'test-secret', listenPorts: [edgePort] }, + ]); + + const connectedPromise = new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('QUIC edge did not connect within 10s')), 10000); + edge.once('tunnelConnected', () => { + clearTimeout(timeout); + resolve(); + }); + }); + + // Track disconnects — any disconnect during the test is a failure signal + edge.on('tunnelDisconnected', () => { + disconnectCount++; + console.log(`[STABILITY] Unexpected tunnel disconnect #${disconnectCount}`); + }); + + await edge.start({ + hubHost: '127.0.0.1', + hubPort, + edgeId: 'test-edge', + secret: 'test-secret', + bindAddress: '127.0.0.1', + transportMode: 'quic', + }); + + await connectedPromise; + await new Promise((resolve) => setTimeout(resolve, 500)); + + const status = await edge.getStatus(); + expect(status.connected).toBeTrue(); +}); + +tap.test('QUIC stability: tunnel stays alive for 30s with periodic echo probes', async () => { + const testDurationMs = 30_000; // 30 seconds + const probeIntervalMs = 5_000; // probe every 5 seconds + const startTime = Date.now(); + let probeCount = 0; + let failedProbes = 0; + + while (Date.now() - startTime < testDurationMs) { + probeCount++; + const elapsed = Math.round((Date.now() - startTime) / 1000); + + // Verify edge still reports connected + const status = await edge.getStatus(); + if (!status.connected) { + throw new Error(`Tunnel disconnected at ${elapsed}s (probe #${probeCount})`); + } + + // Send a 4KB echo probe through the tunnel + const data = crypto.randomBytes(4096); + const hash = sha256(data); + try { + const received = await sendAndReceive(edgePort, data, 10000); + if (received.length !== 4096 || sha256(received) !== hash) { + failedProbes++; + console.log(`[STABILITY] Probe #${probeCount} at ${elapsed}s: data mismatch`); + } else { + console.log(`[STABILITY] Probe #${probeCount} at ${elapsed}s: OK`); + } + } catch (err) { + failedProbes++; + console.log(`[STABILITY] Probe #${probeCount} at ${elapsed}s: FAILED — ${err}`); + } + + // Wait for next probe interval + const remaining = testDurationMs - (Date.now() - startTime); + if (remaining > 0) { + await new Promise((resolve) => setTimeout(resolve, Math.min(probeIntervalMs, remaining))); + } + } + + console.log(`[STABILITY] Completed: ${probeCount} probes, ${failedProbes} failures, ${disconnectCount} disconnects`); + expect(failedProbes).toEqual(0); + expect(disconnectCount).toEqual(0); + + // Final status check + const finalStatus = await edge.getStatus(); + expect(finalStatus.connected).toBeTrue(); +}); + +tap.test('QUIC stability teardown', async () => { + await edge.stop(); + await hub.stop(); + await forceCloseServer(echoServer); +}); + +export default tap.start(); diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 86b7590..5098f58 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@serve.zone/remoteingress', - version: '4.13.2', + version: '4.14.0', description: 'Edge ingress tunnel for DcRouter - tunnels TCP and UDP traffic from the network edge to SmartProxy over TLS or QUIC, preserving client IP via PROXY protocol.' } diff --git a/ts/classes.remoteingressedge.ts b/ts/classes.remoteingressedge.ts index 0cbfd4d..90a575e 100644 --- a/ts/classes.remoteingressedge.ts +++ b/ts/classes.remoteingressedge.ts @@ -79,6 +79,15 @@ export class RemoteIngressEdge extends EventEmitter { plugins.path.join(packageDir, 'rust', 'target', 'debug', 'remoteingress-bin'), ], searchSystemPath: false, + logger: { + log: (level: string, message: string) => { + if (level === 'error') { + console.error(`[RemoteIngressEdge] ${message}`); + } else { + console.log(`[RemoteIngressEdge] ${message}`); + } + }, + }, }); // Forward events from Rust binary diff --git a/ts/classes.remoteingresshub.ts b/ts/classes.remoteingresshub.ts index f6d06d5..0d2a7b9 100644 --- a/ts/classes.remoteingresshub.ts +++ b/ts/classes.remoteingresshub.ts @@ -87,6 +87,15 @@ export class RemoteIngressHub extends EventEmitter { plugins.path.join(packageDir, 'rust', 'target', 'debug', 'remoteingress-bin'), ], searchSystemPath: false, + logger: { + log: (level: string, message: string) => { + if (level === 'error') { + console.error(`[RemoteIngressHub] ${message}`); + } else { + console.log(`[RemoteIngressHub] ${message}`); + } + }, + }, }); // Forward events from Rust binary