把散落在代码里的真实部署值统一收进 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 未被误建)。
169 lines
7.6 KiB
JavaScript
169 lines
7.6 KiB
JavaScript
const cfg = require('../config/index.cjs')
|
||
/**
|
||
* T08 · **域名形态访问**验证(在演练环境做:不动生产、不动 DNS、不动证书)。
|
||
*
|
||
* 要回答的问题:生产切到 cluster(Manager 在 47、实例在 106)后,
|
||
* **按域名形态访问**(`<用户名>.<base-domain>`)还能不能正常落到 106 上的实例?
|
||
*
|
||
* 做法:给演练 Manager 设一个**测试 baseDomain**,用**显式 `Host` 头**打进去。
|
||
*
|
||
* ⚠️ 关键坑(2026-09-15 实际踩到,两次假阳性都源于它):**`fetch` 会静默丢弃 `Host` 头**
|
||
* (Fetch 规范把它列为禁止头,undici 直接忽略)⇒ 请求落到"无租户"的门户路由、回 200 门户页,
|
||
* 看起来"验证通过"其实是假的。⇒ **必须用 curl(`-H Host:`)**,且判据不能只看状态码。
|
||
*
|
||
* 运行(在 47 上):MANAGER=http://127.0.0.1:13080 BASE_DOMAIN=test.<base-domain> \
|
||
* AGENT=http://127.0.0.1:<worker-port-b> AGENT_TOKEN=cross-machine-token \
|
||
* node scripts/verify-cluster-domain.mjs
|
||
*/
|
||
import { execFileSync } from 'node:child_process'
|
||
import { readFileSync } from 'node:fs'
|
||
|
||
const MANAGER = process.env.MANAGER ?? 'http://127.0.0.1:13080'
|
||
const BASE_DOMAIN = process.env.BASE_DOMAIN ?? 'test.example.net'
|
||
const AGENT = process.env.AGENT ?? 'http://127.0.0.1:19000'
|
||
const TOKEN = process.env.AGENT_TOKEN ?? 'cross-machine-token'
|
||
const ADMIN_PW = process.env.ADMIN_PW ?? 'crossmgr123'
|
||
const USER_PW = 'domainuser123'
|
||
|
||
function assert(condition, message) {
|
||
if (!condition) throw new Error('ASSERT: ' + message)
|
||
}
|
||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
||
|
||
const json = async (path, { method = 'GET', body, cookie } = {}) => {
|
||
const res = await fetch(MANAGER + path, {
|
||
method,
|
||
headers: { ...(body ? { 'content-type': 'application/json' } : {}), ...(cookie ? { cookie } : {}) },
|
||
body: body ? JSON.stringify(body) : undefined,
|
||
signal: AbortSignal.timeout(30_000),
|
||
})
|
||
const text = await res.text()
|
||
let parsed = null
|
||
try {
|
||
parsed = text === '' ? null : JSON.parse(text)
|
||
} catch {
|
||
parsed = { raw: text.slice(0, 200) }
|
||
}
|
||
return { status: res.status, body: parsed, setCookie: res.headers.get('set-cookie') }
|
||
}
|
||
|
||
/**
|
||
* 用 **curl** 带 `Host` 头取页面(`-L` 跟随重定向 ⇒ 等价真实浏览器)。
|
||
* 返回 `{ status, body }`,body 从临时文件读(避免编码/二进制问题)。
|
||
*/
|
||
function getByHost(sub, path, cookie) {
|
||
const out = '/tmp/dompage.html'
|
||
const args = [
|
||
'-s',
|
||
'-L',
|
||
'--max-time',
|
||
'25',
|
||
'-o',
|
||
out,
|
||
'-w',
|
||
'%{http_code}',
|
||
'-H',
|
||
`Host: ${sub}.${BASE_DOMAIN}`,
|
||
...(cookie ? ['-b', cookie] : []),
|
||
`${MANAGER}${path}`,
|
||
]
|
||
let status = '0'
|
||
try {
|
||
status = execFileSync('curl', args, { encoding: 'utf8' }).trim()
|
||
} catch {
|
||
status = '0'
|
||
}
|
||
let body = ''
|
||
try {
|
||
body = readFileSync(out, 'utf8')
|
||
} catch {
|
||
body = ''
|
||
}
|
||
return { status: Number(status), body }
|
||
}
|
||
|
||
/**
|
||
* 判据:**dsh 实例页**带 `<base href="/">`(子路径与子域两种形态都带);平台门户页不带。
|
||
* ⚠️ 只靠"含 dsh 字样"会把门户页误判成实例页(实测踩过这个假阳性)。
|
||
*/
|
||
const isDshApp = (html) => typeof html === 'string' && html.includes('<base href=')
|
||
const describe = (html) => {
|
||
const hit = []
|
||
if (isDshApp(html)) hit.push('base-href')
|
||
if (html.includes('/api/auth/login')) hit.push('platform-login')
|
||
const t = /<title>([^<]*)<\/title>/.exec(html)
|
||
return `${hit.join(',') || '(无特征)'} | title=${t === null ? '?' : t[1].trim()} | 首100字: ${html.replace(/\s+/g, ' ').slice(0, 100)}`
|
||
}
|
||
|
||
async function waitRunning(cookie, tries = 60) {
|
||
for (let i = 0; i < tries; i += 1) {
|
||
const st = await json('/api/dsh/status', { cookie })
|
||
if (st.body?.running === true) return true
|
||
await sleep(1000)
|
||
}
|
||
return false
|
||
}
|
||
|
||
try {
|
||
console.log('=== 域名形态验证:baseDomain=%s(Manager=%s)===', BASE_DOMAIN, MANAGER)
|
||
|
||
const adm = await json('/api/auth/login', { method: 'POST', body: { username: 'root', password: ADMIN_PW } })
|
||
assert(adm.status === 200, `管理员登录失败 ${adm.status}`)
|
||
const adminCookie = adm.setCookie.split(';')[0]
|
||
|
||
const uname = `domuser${Date.now() % 100000}`
|
||
let r = await json('/api/auth/register', { method: 'POST', body: { username: uname, password: USER_PW } })
|
||
assert(r.status === 201, `注册应 201(实际 ${r.status})`)
|
||
const users = await json('/api/admin/users', { cookie: adminCookie })
|
||
const target = users.body.users.find((u) => u.username === uname)
|
||
assert(target !== undefined, '能看到待审用户')
|
||
await json(`/api/admin/users/${target.id}/approve`, { method: 'POST', cookie: adminCookie })
|
||
const login = await json('/api/auth/login', { method: 'POST', body: { username: uname, password: USER_PW } })
|
||
assert(login.status === 200, `用户登录失败 ${login.status}`)
|
||
const cookie = login.setCookie.split(';')[0]
|
||
console.log('① 用户 -> %s(uid=%s)', uname, target.id)
|
||
|
||
r = await json('/api/fs/mkdir', { method: 'POST', cookie, body: { path: 'proj' } })
|
||
assert(r.status === 200, `mkdir 失败 ${r.status}`)
|
||
r = await json('/api/dsh/launch', { method: 'POST', cookie, body: { folder: 'proj' } })
|
||
assert(r.status === 200, `launch 失败 ${r.status} ${JSON.stringify(r.body)}`)
|
||
assert(await waitRunning(cookie), '实例应 running')
|
||
console.log('② 拉起 -> running=true')
|
||
|
||
const enter = await json('/api/dsh/enter', { method: 'POST', cookie })
|
||
assert(enter.status === 200, `enter 失败 ${enter.status}`)
|
||
const url = enter.body.url
|
||
console.log('③ 直达 URL -> %s', url)
|
||
assert(url.startsWith('https://'), `baseDomain 生效时应为 https://<子域>/(实际 ${url})`)
|
||
assert(url.includes(`${uname}.${BASE_DOMAIN}`), `URL 应含用户名子域(实际 ${url})`)
|
||
|
||
// ④ 子域形态访问:**必须带 token**(真 dsh 没 token 只给自己的登录页)
|
||
const token = new URL(url).searchParams.get('token') ?? ''
|
||
assert(token !== '', `enter URL 应带 token(实际 ${url})`)
|
||
let page = { status: 0, body: '' }
|
||
for (let i = 0; i < 40; i += 1) {
|
||
page = getByHost(uname, `/?token=${encodeURIComponent(token)}`, cookie)
|
||
if (page.status === 200 && isDshApp(page.body)) break
|
||
await sleep(1000)
|
||
}
|
||
assert(page.status === 200, `子域访问应 200(实际 ${page.status})`)
|
||
assert(isDshApp(page.body), `子域访问必须是**真的 dsh 实例页**(实际 ${describe(page.body)})`)
|
||
console.log('④ 子域访问 -> 200 且是**真 dsh 实例页**(Host: %s.%s → 106 上的实例)', uname, BASE_DOMAIN)
|
||
|
||
// ⑤ 越权对照:拿 A 的 cookie 访问**另一个真实用户**(root)的子域 ⇒ 必须 401/403
|
||
const other = getByHost('root', `/?token=${encodeURIComponent(token)}`, cookie)
|
||
assert(!isDshApp(other.body), `越权响应绝不能是实例页(实际 ${describe(other.body)})`)
|
||
assert([401, 403].includes(other.status), `用 A 的 cookie 访问 root 子域应 401/403(实际 ${other.status})`)
|
||
console.log('⑤ 越权对照 -> 用 A 的 cookie 访问 root 子域 = %d(正确拒绝)', other.status)
|
||
|
||
// ⑥ 独立取证:实例确实在 106
|
||
const onAgent = await fetch(`${AGENT}/instances`, { headers: { 'x-dsh-agent-token': TOKEN } }).then((x) => x.json())
|
||
assert(onAgent.instances.length >= 1, 'worker(106) 上应有实例')
|
||
console.log('⑥ 取证 -> 实例确实在 106(worker /instances=%d)', onAgent.instances.length)
|
||
|
||
await json('/api/dsh/stop', { method: 'POST', cookie })
|
||
console.log('\nOK: **域名形态访问**在 cluster 下可用(子域 → Manager(47) → 实例(106)),且越权被拒')
|
||
} finally {
|
||
/* 现场保留 */
|
||
}
|