Initial STUN NAT mapping console

This commit is contained in:
hhqyb
2026-07-16 15:07:12 +08:00
commit e38ef6ef05
17 changed files with 858 additions and 0 deletions

58
test/api.test.js Normal file
View File

@@ -0,0 +1,58 @@
import assert from 'node:assert/strict';
import { after, before, test } from 'node:test';
import { spawn } from 'node:child_process';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'stunmap-test-'));
const port = 17991;
const auth = `Basic ${Buffer.from('admin:test-password').toString('base64')}`;
let app;
async function request(url, options = {}) {
return fetch(`http://127.0.0.1:${port}${url}`, { ...options, headers: { authorization: auth, 'content-type': 'application/json', ...(options.headers || {}) } });
}
before(async () => {
app = spawn(process.execPath, ['server.js'], { env: { ...process.env, PORT: String(port), STUNMAP_DATA_DIR: directory, NATMAP_BIN: '/does/not/exist', STUNMAP_ADMIN_PASSWORD: 'test-password' } });
await new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error('server did not start')), 3000); app.stdout.on('data', () => { clearTimeout(timer); resolve(); }); app.on('error', reject); });
});
after(async () => { app.kill('SIGTERM'); await fs.rm(directory, { recursive: true, force: true }); });
test('requires HTTP basic authentication', async () => {
const response = await fetch(`http://127.0.0.1:${port}/api/rules`);
assert.equal(response.status, 401);
});
test('creates, validates, and stops a Lucky-style STUN rule', async () => {
const body = { name: 'web', enabled: false, protocol: 'tcp', bindPort: 0, stunServer: 'turn.cloudflare.com:3478', keepaliveServer: 'www.cloudflare.com:80', interval: 30, checkEvery: 10, mode: 'forward', targetHost: '192.168.1.20', targetPort: 443, routerMapping: 'upnp', firewallNote: 'WAN allowed' };
const created = await request('/api/rules', { method: 'POST', body: JSON.stringify(body) });
assert.equal(created.status, 201); const rule = (await created.json()).rule;
assert.equal(rule.protocol, 'tcp'); assert.equal(rule.mode, 'forward'); assert.equal(rule.routerMapping, 'upnp');
const list = await request('/api/rules'); const rules = (await list.json()).rules;
assert.equal(rules.length, 1); assert.equal(rules[0].running, false);
const invalid = await request('/api/rules', { method: 'POST', body: JSON.stringify({ ...body, name: 'bad', protocol: 'udp', mode: 'forward', targetPort: 0 }) });
assert.equal(invalid.status, 400);
const udpBind = await request('/api/rules', { method: 'POST', body: JSON.stringify({ ...body, name: 'udp-bind', protocol: 'udp', mode: 'bind', targetHost: '', targetPort: 0 }) });
assert.equal(udpBind.status, 400);
const incompleteProbe = await request('/api/rules', { method: 'POST', body: JSON.stringify({ ...body, name: 'incomplete-probe', externalProbeUrl: 'https://probe.example.com/probe' }) });
assert.equal(incompleteProbe.status, 400);
const webhook = await request('/api/rules', { method: 'POST', body: JSON.stringify({ ...body, name: 'webhook-template', webhookUrl: 'https://hooks.example.com/{{STUN_PUBLIC_ADDR}}', webhookMethod: 'post', webhookBody: '{"ip":"{{STUN_PUBLIC_IP}}","port":{{STUN_PUBLIC_PORT}},"name":"{{STUN_RULE_NAME}}"}' }) });
assert.equal(webhook.status, 201); const webhookRule = (await webhook.json()).rule;
assert.equal(webhookRule.webhookBody, '{"ip":"{{STUN_PUBLIC_IP}}","port":{{STUN_PUBLIC_PORT}},"name":"{{STUN_RULE_NAME}}"}');
const invalidWebhookBody = await request('/api/rules', { method: 'POST', body: JSON.stringify({ ...body, name: 'invalid-webhook-template', webhookUrl: 'https://hooks.example.com', webhookMethod: 'post', webhookBody: '{"ip":"{{MISSING_VARIABLE}}"}' }) });
assert.equal(invalidWebhookBody.status, 400);
const removed = await request(`/api/rules/${rule.id}`, { method: 'DELETE' });
assert.equal(removed.status, 204);
});
test('keeps the console available when an enabled mapping engine cannot start', async () => {
const body = { name: 'engine-error', enabled: true, protocol: 'udp', bindPort: 0, stunServer: 'turn.cloudflare.com:3478', keepaliveServer: '', interval: 30, checkEvery: 10, mode: 'forward', targetHost: '192.168.1.20', targetPort: 51820, routerMapping: 'none' };
const created = await request('/api/rules', { method: 'POST', body: JSON.stringify(body) });
assert.equal(created.status, 201);
await new Promise((resolve) => setTimeout(resolve, 40));
const rules = (await (await request('/api/rules')).json()).rules;
const rule = rules.find((item) => item.name === 'engine-error');
assert.ok(rule.logs.some((entry) => entry.level === 'error'));
});

48
test/engine.test.js Normal file
View File

@@ -0,0 +1,48 @@
import assert from 'node:assert/strict';
import dgram from 'node:dgram';
import net from 'node:net';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { spawn } from 'node:child_process';
import { after, test } from 'node:test';
const engine = process.env.NATMAP_BIN;
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'stunmap-engine-'));
const children = [];
const sockets = [];
after(async () => { for (const child of children) child.kill('SIGTERM'); for (const socket of sockets) socket.close(); await fs.rm(directory, { recursive: true, force: true }); });
function bind(socket) { return new Promise((resolve) => socket.bind(0, '127.0.0.1', () => resolve(socket.address().port))); }
function listen(server) { return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve(server.address().port))); }
function waitFor(predicate, timeout = 5000) { return new Promise((resolve, reject) => { const deadline = Date.now() + timeout; const timer = setInterval(async () => { try { const value = await predicate(); if (value) { clearInterval(timer); resolve(value); } else if (Date.now() >= deadline) { clearInterval(timer); reject(new Error('timed out waiting for mapping')); } } catch (error) { clearInterval(timer); reject(error); } }, 40); }); }
test('NATMap UDP forward mode maps a STUN channel port to the local target', { skip: !engine }, async () => {
const stun = dgram.createSocket('udp4'); sockets.push(stun); const stunPort = await bind(stun);
stun.on('message', (message, remote) => {
if (message.length < 20 || message.readUInt16BE(0) !== 1) return;
const response = Buffer.alloc(32); response.writeUInt16BE(0x0101, 0); response.writeUInt16BE(12, 2); message.copy(response, 4, 4, 20); response.writeUInt16BE(0x0020, 20); response.writeUInt16BE(8, 22); response[25] = 1; response.writeUInt16BE(remote.port ^ 0x2112, 26); Buffer.from([0x7f ^ 0x21, 0x00 ^ 0x12, 0x00 ^ 0xa4, 0x01 ^ 0x42]).copy(response, 28); stun.send(response, remote.port, remote.address);
});
const echo = dgram.createSocket('udp4'); sockets.push(echo); const echoPort = await bind(echo); echo.on('message', (message, remote) => echo.send(message, remote.port, remote.address));
const stateDir = path.join(directory, 'state'); await fs.mkdir(stateDir); const child = spawn(engine, ['-u', '-s', `127.0.0.1:${stunPort}`, '-b', '0', '-k', '1', '-c', '1', '-t', '127.0.0.1', '-p', String(echoPort), '-e', path.resolve('scripts/notify.js')], { env: { ...process.env, STUNMAP_RULE_ID: 'udp', STUNMAP_STATE_DIR: stateDir } }); children.push(child);
let stderr = ''; child.stderr.on('data', (chunk) => { stderr += chunk; });
const state = await waitFor(async () => { try { return JSON.parse(await fs.readFile(path.join(stateDir, 'udp.json'), 'utf8')); } catch { return null; } });
const client = dgram.createSocket('udp4'); sockets.push(client); const response = await new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error(`UDP forwarding timed out: ${stderr}`)), 3000); client.on('message', (message) => { clearTimeout(timer); resolve(message.toString()); }); client.send(Buffer.from('stunmap-ok'), state.privatePort, '127.0.0.1'); });
assert.equal(response, 'stunmap-ok');
const initialHeartbeat = state.lastVerifiedAt; await new Promise((resolve) => setTimeout(resolve, 1200)); const refreshed = JSON.parse(await fs.readFile(path.join(stateDir, 'udp.json'), 'utf8'));
assert.notEqual(refreshed.lastVerifiedAt, initialHeartbeat, 'each successful STUN check must refresh mapping health');
echo.close(); sockets.splice(sockets.indexOf(echo), 1);
const probe = dgram.createSocket('udp4'); sockets.push(probe); const probeResponse = await new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error('UDP channel health response timed out')), 3000); probe.on('message', (message) => { clearTimeout(timer); resolve(message.toString()); }); probe.send(Buffer.from('SMHP:target-is-irrelevant'), refreshed.privatePort, '127.0.0.1'); });
assert.equal(probeResponse, 'SMHP:target-is-irrelevant');
});
test('NATMap TCP forward mode keeps a channel and proxies TCP traffic', { skip: !engine }, async () => {
const keepalive = net.createServer((socket) => socket.on('data', () => socket.write('HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n'))); const keepalivePort = await listen(keepalive);
const stun = net.createServer((socket) => socket.once('data', (message) => { if (message.length < 20 || message.readUInt16BE(0) !== 1) return socket.destroy(); const remote = socket.address(); const response = Buffer.alloc(32); response.writeUInt16BE(0x0101, 0); response.writeUInt16BE(12, 2); message.copy(response, 4, 4, 20); response.writeUInt16BE(0x0020, 20); response.writeUInt16BE(8, 22); response[25] = 1; response.writeUInt16BE(remote.port ^ 0x2112, 26); Buffer.from([0x7f ^ 0x21, 0x00 ^ 0x12, 0x00 ^ 0xa4, 0x01 ^ 0x42]).copy(response, 28); socket.end(response); })); const stunPort = await listen(stun);
const echo = net.createServer((socket) => socket.pipe(socket)); const echoPort = await listen(echo); after(() => { keepalive.close(); stun.close(); echo.close(); });
const stateDir = path.join(directory, 'tcp-state'); await fs.mkdir(stateDir); const child = spawn(engine, ['-s', `127.0.0.1:${stunPort}`, '-h', `127.0.0.1:${keepalivePort}`, '-b', '0', '-k', '1', '-t', '127.0.0.1', '-p', String(echoPort), '-e', path.resolve('scripts/notify.js')], { env: { ...process.env, STUNMAP_RULE_ID: 'tcp', STUNMAP_STATE_DIR: stateDir } }); children.push(child);
let stderr = ''; child.stderr.on('data', (chunk) => { stderr += chunk; });
const state = await waitFor(async () => { try { return JSON.parse(await fs.readFile(path.join(stateDir, 'tcp.json'), 'utf8')); } catch { return null; } });
const reply = await new Promise((resolve, reject) => { const socket = net.connect(state.privatePort, '127.0.0.1'); const timer = setTimeout(() => { socket.destroy(); reject(new Error(`TCP forwarding timed out: ${stderr}`)); }, 3000); socket.on('connect', () => socket.write('stunmap-tcp')); socket.on('data', (chunk) => { clearTimeout(timer); socket.end(); resolve(chunk.toString()); }); socket.on('error', reject); });
assert.equal(reply, 'stunmap-tcp');
});

21
test/probe.test.js Normal file
View File

@@ -0,0 +1,21 @@
import assert from 'node:assert/strict';
import dgram from 'node:dgram';
import net from 'node:net';
import { after, before, test } from 'node:test';
import { spawn } from 'node:child_process';
const port = 17992; const token = 'probe-test-token'; let probe;
function listen(server) { return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve(server.address().port))); }
async function request(body) { return fetch(`http://127.0.0.1:${port}/probe`, { method:'POST', headers:{'content-type':'application/json'}, body:JSON.stringify({ token, ...body }) }); }
before(async () => { probe=spawn(process.execPath,['probe-server.js'],{env:{...process.env,PROBE_PORT:String(port),STUNMAP_PROBE_TOKEN:token}}); await new Promise((resolve,reject)=>{const timer=setTimeout(()=>reject(new Error('probe did not start')),3000); probe.stdout.on('data',()=>{clearTimeout(timer);resolve();}); probe.on('error',reject);}); });
after(() => probe.kill('SIGTERM'));
test('public probe checks TCP channel handshake without application payload', async () => {
const server=net.createServer((socket)=>socket.destroy()); const targetPort=await listen(server); const response=await request({address:'127.0.0.1',port:targetPort,protocol:'tcp'}); assert.equal(response.status,200); assert.equal((await response.json()).reachable,true); server.close();
});
test('public probe checks UDP channel echo', async () => {
const server=dgram.createSocket('udp4'); const targetPort=await new Promise((resolve)=>server.bind(0,'127.0.0.1',()=>resolve(server.address().port))); server.on('message',(message,remote)=>server.send(message,remote.port,remote.address)); const response=await request({address:'127.0.0.1',port:targetPort,protocol:'udp'}); assert.equal(response.status,200); assert.equal((await response.json()).reachable,true); server.close();
});
test('public probe rejects invalid token', async () => {
const response=await fetch(`http://127.0.0.1:${port}/probe`,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({token:'wrong',address:'127.0.0.1',port:1,protocol:'tcp'})}); assert.equal(response.status,403);
});