Files
dsh_shenxian/scripts/overlay-holepunch.cjs
T
admin 452924d89c feat(config): 涉密内容外置到配置目录(档案 140)
把散落在代码里的真实部署值统一收进 config/,代码改为引用配置,
使仓库副本/开源导出不再带出生产域名、IP、内网路径与凭据。

新增 config/:platform.env.example(模板)· load.sh(shell 加载器)·
index.cjs(node 加载器)· README.md(键一览与优先级)。
真实值放 config/platform.env —— 已 .gitignore 排除,不入库、不进导出。

TS 侧新增 src/platform-paths.ts 作部署路径的唯一解析处(零副作用):
platformDir/stateDir/backupDir/artifactDir/installDir/scriptPath。
config.ts 接入这些字段;内置中继种子由生产 URL 改为空(改由
DSHS_OVERLAY_BOOTSTRAP_SEEDS 提供)。修掉 5 处硬编码绝对路径,
src/** 注释中性化 116 行/53 文件。

scripts/** 36 个内部运维脚本:真令牌/PG 口令/隧道目标/主机号/路径
一律改从配置取;web/wake.html 的注册域白名单改为运行时从
location.hostname 推导;test/** 夹具 119 行/13 文件改 RFC 2606/5737
保留值,并把「内置种子必须为空」固化为回归断言。

取证:tsc 0 错;npm test 373/375(唯一失败 lease 属既有);
全仓扫描(大小写不敏感)代码面涉密标识 = 0;已部署 47 并零回归
(/opt/dsh/* 未搬家,/var/lib/dshs/platform 未被误建)。
2026-09-19 15:12:19 +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 <server-public-ip> --port-x 21100 --port-y 21101 \
* --token <t> --name w-dev --peers <host-a>u,<host-b>u
*
* @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)
}