Files
dsh_shenxian/scripts/overlay-holepunch.cjs
T
admin 146c3d25ef feat(overlay): 覆盖网络线序①–⑮ 代码与测试产物入库
覆盖网络线累积产物(此前只在工作区、未入版本库):
- 新增 relay 子系统 src/net/relay/**(wire/duplex/server/client/dialer/switcher/directory/identity/keys/placement/network/addr-override/main/index)
- 新增 src/worker/relay-tunnel.ts、src/web/routes/overlay.ts
- 新增观测/演练脚本 overlay-probe、overlay-failover-drill、overlay-keyring、overlay-holepunch、overlay-jitter、overlay-wan、overlay-relaykey-add、relay-mem-calibrate
- 新增测试 12 个(relay / relay-failover / remote-spawner / instance-port / overlay-{network,auth,bootstrap,identity} / remote-user-fs 等)

验收基线:npm test = 162 pass / 0 fail / 1 skip;--scene all = 12 PASS / 0 SKIP / 0 FAIL;overlay-probe = 12/12
2026-09-17 17:03:27 +08:00

303 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* 覆盖网络 序⑥ · S4 打洞可行性探测(⛔ 一次性脚本、不进产品路径)。
*
* ## 口径(🔴 本单最重要的一条已定项)
* 本脚本测的是**「该网络能不能打洞」**(NAT 映射/过滤行为),
* ⛔ **不是**"本系统打洞成功率" —— 仓库全仓零 UDP/穿透代码(`src/` 无 dgram/STUN),
* 后者需要先有实现(见交接单_最小形态真机批次_20260917 §4.1-1)。
*
* ## 两个角色
* - `--observer`(**跑在 47**,唯一具备公网直连观察面的一端):绑两个 UDP 口
* (两个口是**故意的** —— 同一个 socket 发向两个不同目的口,若两次看到的源口相同 ⇒
* 该 NAT 是**端点无关映射**(cone 型),这正是可打洞的主判据),登记 `name → ip:port`;
* - `--probe`(跑在每个节点):同一个 socket 依次 `REG` 到两个观察口 → 拿到**自己的两次映射**
* → 向观察口要 `PEERS` 表 → 向每个对端的映射每 `--gap` ms 发 1 包共 `--rounds` 包,
* 全程收包 ⇒ 记录"收到了谁"。
*
* ## 判据
* - 任一方收到对方 ≥ 1 包 ⇒ 该**方向**可打洞;逐对两方向分别记;
* - `mappingX === mappingY` ⇒ 端点无关映射(cone);不等 ⇒ 对称型(打洞概率低);
* - **包到达但被本机防火墙拦掉**与"对端没发出来"在数据上同形 ⇒ 因此每次实验都带
* **同机回环控制对**(47 的观察面自身既是 sender 也是 receiver,见 §8 的对照说明)。
*
* 用法:
* node overlay-holepunch.cjs --observer --port-x 21100 --port-y 21101 --token <t> --secs 90
* node overlay-holepunch.cjs --probe --obs 47.77.182.89 --port-x 21100 --port-y 21101 \
* --token <t> --name w-dev --peers w-47u,w-106u
*
* @module scripts/overlay-holepunch
*/
'use strict'
const dgram = require('node:dgram')
function parseArgs(argv) {
const out = {}
for (let i = 0; i < argv.length; i++) {
const a = argv[i]
if (!a.startsWith('--')) continue
const k = a.slice(2)
const v = argv[i + 1]
if (v === undefined || v.startsWith('--')) out[k] = true
else {
out[k] = isNaN(Number(v)) || v === '' ? v : Number(v)
i++
}
}
return out
}
const args = parseArgs(process.argv.slice(2))
const log = (s) => process.stderr.write(`[hp] ${s}\n`)
const out = (o) => require('node:fs').writeSync(1, `### RESULT ### ${JSON.stringify(o)}\n`)
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
// ─────────────────────────── observer ───────────────────────────
function observer(portX, portY, token, secs) {
/** name → { X: 'ip:port', Y: 'ip:port' } */
const reg = {}
const seen = []
const socks = []
const mk = (tag, port) => {
const s = dgram.createSocket('udp4')
s.on('error', (e) => log(`obs ${tag} error ${e.message}`))
s.on('message', (msg, rinfo) => {
const src = `${rinfo.address}:${rinfo.port}`
const text = msg.toString('utf8')
if (!text.startsWith(token)) {
seen.push({ tag, src, text: text.slice(0, 40), at: Date.now() })
return
}
const parts = text.split(' ')
const cmd = parts[1]
if (cmd === 'REG') {
const name = parts[2]
reg[name] = reg[name] || {}
reg[name][tag] = src
s.send(`${token} ACK ${tag} ${src}`, rinfo.port, rinfo.address)
} else if (cmd === 'PEERS') {
s.send(`${token} PEERS ${JSON.stringify(reg)}`, rinfo.port, rinfo.address)
}
})
s.bind(port, '0.0.0.0', () => log(`observer ${tag} 绑 0.0.0.0:${port}`))
socks.push(s)
}
mk('X', portX)
mk('Y', portY)
setTimeout(() => {
out({ role: 'observer', reg, unauthenticated: seen.slice(0, 20), unauthenticatedCount: seen.length })
process.exit(0)
}, secs * 1000)
}
// ─────────────────────────── probe ───────────────────────────
async function probe(o) {
const token = o.token
const name = o.name
const peers = String(o.peers || '').split(',').filter((s) => s !== '')
const sock = dgram.createSocket('udp4')
const receipts = {}
const acks = {}
let peersTable = null
sock.on('error', (e) => log(`probe error ${e.message}`))
sock.on('message', (msg, rinfo) => {
const text = msg.toString('utf8')
if (!text.startsWith(token)) return
const parts = text.split(' ')
const src = `${rinfo.address}:${rinfo.port}`
if (parts[1] === 'ACK') acks[parts[2]] = parts[3]
else if (parts[1] === 'PEERS') {
try {
peersTable = JSON.parse(text.slice(token.length + 7))
} catch (e) {
log(`PEERS 解析失败:${e.message}`)
}
} else if (parts[1] === 'PUNCH') {
receipts[src] = (receipts[src] || 0) + 1
}
})
await new Promise((r) => sock.bind(0, '0.0.0.0', r))
const localPort = sock.address().port
const to = (port) => new Promise((r) => sock.send(`${token} REG ${name}`, port, o.obs, r))
for (let i = 0; i < 5; i++) {
await to(o['port-x'])
await sleep(200)
}
for (let i = 0; i < 5; i++) {
await to(o['port-y'])
await sleep(200)
}
for (let i = 0; i < 10 && peersTable === null; i++) {
sock.send(`${token} PEERS ${name}`, o['port-x'], o.obs)
await sleep(300)
}
if (peersTable === null) {
out({ role: 'probe', name, localPort, error: 'no-peers-table', acks })
process.exit(0)
}
const myX = (peersTable[name] || {}).X
const myY = (peersTable[name] || {}).Y
const targets = {}
for (const p of peers) {
const m = (peersTable[p] || {}).X
if (m === undefined) continue
targets[p] = m
}
const rounds = o.rounds || 10
const gap = o.gap || 200
const sent = {}
for (const [p, addr] of Object.entries(targets)) {
sent[p] = 0
const [ip, port] = addr.split(':')
for (let i = 0; i < rounds; i++) {
sock.send(`${token} PUNCH ${name}`, Number(port), ip)
sent[p]++
await sleep(gap)
}
}
await sleep(1500)
const byPeer = {}
for (const [p, addr] of Object.entries(targets)) {
byPeer[p] = { target: addr, sent: sent[p], received: receipts[addr] || 0 }
}
const other = Object.entries(receipts).filter(([a]) => !Object.values(targets).includes(a))
out({
role: 'probe',
name,
localPort,
mappingX: myX,
mappingY: myY,
mappingEndpointIndependent: myX !== undefined && myX === myY,
peers: byPeer,
receiptsFromUnexpected: other,
acks,
})
process.exit(0)
}
// ─────────────────────────── STUN(降级路径) ───────────────────────────
/**
* 🔴 **为什么需要降级**:首选路径(47 上一次性 UDP 观察器)**实测不可用** ——
* 观察器零收包(连同机发出的包都收不到)⇒ 47 的**云安全组拦掉了 UDP 入站**
* (⛔ 不是 nft:`nft` 的 `input policy` = `accept`)。改走公网 STUN 做映射/过滤判定。
* ⛔ 按 S4 要求,用本模式得到的一切结论**必须在 §8 标注"经第三方"**。
*/
const MAGIC = 0x2112a442
function stunRequest(sock, host, port, cb) {
const buf = Buffer.alloc(20)
buf.writeUInt16BE(0x0001, 0)
buf.writeUInt16BE(0, 2)
buf.writeUInt32BE(MAGIC, 4)
require('node:crypto').randomBytes(12).copy(buf, 8)
let done = false
const onMsg = (msg) => {
if (msg.length < 20 || msg.readUInt16BE(0) !== 0x0101) return
let off = 20
const end = 20 + msg.readUInt16BE(2)
while (off + 4 <= end && off + 4 <= msg.length) {
const type = msg.readUInt16BE(off)
const len = msg.readUInt16BE(off + 2)
const val = msg.subarray(off + 4, off + 4 + len)
if ((type === 0x0020 || type === 0x0001) && val.length >= 8) {
const family = val[1]
const rawPort = val.readUInt16BE(2)
const p = type === 0x0020 ? rawPort ^ (MAGIC >>> 16) : rawPort
let ip
if (family === 1) {
ip = [...val.subarray(4, 8)].join('.')
if (type === 0x0020) {
const b = val.subarray(4, 8)
const m = Buffer.alloc(4)
m.writeUInt32BE(MAGIC, 0)
ip = [...b].map((x, i) => x ^ m[i]).join('.')
}
}
if (!done && ip !== undefined) {
done = true
sock.off('message', onMsg)
cb({ server: `${host}:${port}`, mapping: `${ip}:${p}`, attr: type === 0x0020 ? 'XOR-MAPPED' : 'MAPPED' })
}
return
}
off += 4 + len + ((4 - (len % 4)) % 4)
}
}
sock.on('message', onMsg)
sock.send(buf, port, host)
setTimeout(() => {
if (!done) {
done = true
sock.off('message', onMsg)
cb({ server: `${host}:${port}`, error: 'no-response' })
}
}, 4000)
}
const writeLine = (s) => require('node:fs').writeSync(1, `${s}\n`)
async function stun(o) {
const sock = dgram.createSocket('udp4')
await new Promise((r) => sock.bind(Number(o.bind || 0), '0.0.0.0', r))
const localPort = sock.address().port
const receipts = {}
sock.on('message', (msg, rinfo) => {
const key = `${rinfo.address}:${rinfo.port}`
receipts[key] = (receipts[key] || 0) + 1
writeLine(`RECV ${key} ${msg.length}B`)
})
const servers = String(o.stun || '')
.split(',')
.filter((s) => s !== '')
.map((s) => {
const [h, p] = s.split(':')
return { h, p: Number(p) }
})
const mappings = []
for (const s of servers) {
// eslint-disable-next-line no-await-in-loop
const r = await new Promise((res) => stunRequest(sock, s.h, s.p, res))
mappings.push(r)
writeLine(`MAPPING ${JSON.stringify(r)}`)
}
const nodes = [...new Set(mappings.filter((m) => m.mapping).map((m) => m.mapping))]
writeLine(`### MAPPING ### ${JSON.stringify({ name: o.name, localPort, mappings, distinctMappings: nodes })}`)
const to = typeof o.to === 'string' ? o.to.split(',').filter((s) => s !== '') : []
let sent = 0
for (const target of to) {
const [ip, port] = target.split(':')
for (let i = 0; i < (o.rounds || 10); i++) {
sock.send(`${o.name || 'probe'} PUNCH`, Number(port), ip)
sent++
// eslint-disable-next-line no-await-in-loop
await sleep(o.gap || 200)
}
writeLine(`SENT ${(o.rounds || 10)} -> ${target}`)
}
await sleep((o.listen || 20) * 1000)
out({
role: 'stun',
name: o.name,
localPort,
mappings,
endpointIndependentMapping: nodes.length === 1,
distinctMappings: nodes,
punchedTo: to,
punchesSent: sent,
receipts,
via: 'third-party STUN(降级路径)',
})
process.exit(0)
}
if (args.observer) observer(args['port-x'], args['port-y'], args.token, args.secs || 90)
else if (args.probe) probe(args)
else if (args.stun) stun(args)
else {
process.stderr.write('usage: --observer --port-x N --port-y N --token T --secs S | --probe --obs H --port-x N --port-y N --token T --name N --peers a,b | --stun --name N [--bind P] --stun s1:p1,s2:p2 [--to ip:port] [--listen S]\n')
process.exit(2)
}