背景:把平台从「单机单进程」改造成「1 组 Manager + N 台 Worker + 共享归属状态」,
硬约束 = 全程兼容单例模式(deployMode 默认 local;生产切换前 47 一行未动)。
主要改动
1) 数据模型 v7(SQLite 与 PG 两方言同步):新增 dsh_hosts 注册表 +
dsh_instances.{host_id,epoch,heartbeat_at,lease_until};claimInstance 原子抢占
(UPDATE … WHERE host_id IS NULL OR lease_until < now)+ pinInstanceHost 钉住归属。
2) 租约与 fencing:src/supervisor/lease.ts(acquire/renew/release + stillHolder 判据 +
ttl > 2×renew 硬校验);心跳里续租,失权即向 worker 下发更高 epoch(self-fencing)。
⚠️ release 只清租约(lease_until),**保留 host_id** —— host_id 是「用户数据在哪台」的锚点。
3) Worker agent(src/worker/agent.ts,子命令 dshs worker):实例生命周期 + 文件面 /fs/*
+ 幂等键(operationId)+ 鉴权(timingSafeEqual);Worker 不写控制面数据
(apiKey/uid 由 Manager 随 launch 投递,R5 收窄)。
4) 远端 Spawner + LeasedSpawner:按 host 路由(**粘性优先**:有历史归属且那台 up 就留在原地,
否则按容量选最空的)+ 容量准入 + deployMode=cluster 装配(systemd drop-in,可回滚)。
5) bwrap 修正:**所有挂载点的中间目录统一前置 + 去重 + 由外到内**(「就近创建」会在嵌套前缀下
遮掉已绑挂载点 ⇒ bwrap: Can't chdir);且**只能用 --tmpfs**,用 --perms 会让 47 的
bwrap 0.4.0 直接拒启动(沙箱全挂)。
6) 跨机隧道 src/worker/tunnel.ts:SSH ControlMaster + 动态 -R 转发;**自愈由 agent 本地
20s 定时器驱动**(不能只放 /healthz —— 心跳本身经隧道进来,断了就没人触发它)。
7) 文件面按归属路由(RemoteUserFs):实例与文件必须落在同一台机器,否则实例看不到自己的文件。
8) 观测面:dshs doctor / dshs cluster status。
验证(本次均已实跑)
- test/lease.test.mjs:SQLite 10/10 == PG 10/10
- 组件级端到端 5 个:verify-cluster-{agent,lease,fs,migrate,live}.mjs
- 真跨机(47 Manager / 106 Worker,跨云 + 反向隧道)verify-cluster-cross.mjs 九步全绿
- 域名形态访问 verify-cluster-domain.mjs(<user>.域名 → Manager → 远端实例;越权 403)
- 冒烟 scripts/smoke-*:6/8,失败项与改动前基线完全相同(无回归)
- 生产切换与回滚剧本见 dsh-server-docs/交接单/T08-集群化落地-兼容单例模式.md §16
111 lines
4.3 KiB
JavaScript
111 lines
4.3 KiB
JavaScript
// Subdomain routing + auth flow: a per-user `Host: <username>.<baseDomain>` routes
|
||
// to that user's DSH only when the caller's session cookie matches the subdomain.
|
||
import { request as httpRequest } from 'node:http'
|
||
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'
|
||
import { tmpdir } from 'node:os'
|
||
import { dirname, join } from 'node:path'
|
||
import { fileURLToPath } from 'node:url'
|
||
import { buildServer } from '../lib/web/server.js'
|
||
import { resolveConfig } from '../lib/config.js'
|
||
import { hashPassword } from '../lib/web/auth.js'
|
||
|
||
function assert(condition, message) {
|
||
if (!condition) throw new Error('ASSERT: ' + message)
|
||
}
|
||
|
||
const here = dirname(fileURLToPath(import.meta.url))
|
||
const fakeDsh = join(here, 'fake-dsh.mjs')
|
||
const dataRoot = mkdtempSync(join(tmpdir(), 'dsh-smoke-subdomain-'))
|
||
|
||
const app = await buildServer(
|
||
resolveConfig({ port: 0, dbPath: ':memory:', dataRoot, baseDomain: 'test.local', dshCommand: [process.execPath, fakeDsh] }),
|
||
)
|
||
await app.listen({ port: 0 })
|
||
const port = app.server.address().port
|
||
|
||
await app.db.createUser({
|
||
id: 'u1',
|
||
username: 'Carol',
|
||
passHash: await hashPassword('carolpass123'),
|
||
role: 'active',
|
||
homeDir: '/tmp/u1-home',
|
||
})
|
||
await app.db.createUser({
|
||
id: 'u2',
|
||
username: 'bob',
|
||
passHash: await hashPassword('bobpass123'),
|
||
role: 'active',
|
||
homeDir: '/tmp/u2-home',
|
||
})
|
||
mkdirSync(join(dataRoot, 'users', 'u1', 'ws', 'proj'), { recursive: true })
|
||
|
||
async function json(path, { method = 'GET', body, cookie } = {}) {
|
||
const res = await fetch(`http://127.0.0.1:${port}${path}`, {
|
||
method,
|
||
headers: { ...(body ? { 'content-type': 'application/json' } : {}), ...(cookie ? { cookie } : {}) },
|
||
body: body ? JSON.stringify(body) : undefined,
|
||
})
|
||
const text = await res.text()
|
||
return { status: res.status, body: text ? JSON.parse(text) : null, setCookie: res.headers.get('set-cookie') }
|
||
}
|
||
|
||
function getWithHost(path, host, cookie) {
|
||
return new Promise((resolve, reject) => {
|
||
const req = httpRequest(
|
||
{ hostname: '127.0.0.1', port, path, method: 'GET', headers: { host, ...(cookie ? { cookie } : {}) } },
|
||
(res) => {
|
||
let data = ''
|
||
res.on('data', (chunk) => (data += chunk))
|
||
res.on('end', () => resolve({ status: res.statusCode, body: data }))
|
||
},
|
||
)
|
||
req.on('error', reject)
|
||
req.end()
|
||
})
|
||
}
|
||
|
||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
||
|
||
try {
|
||
let r = await json('/api/auth/login', { method: 'POST', body: { username: 'Carol', password: 'carolpass123' } })
|
||
const cookie = r.setCookie.split(';')[0]
|
||
r = await json('/api/auth/login', { method: 'POST', body: { username: 'bob', password: 'bobpass123' } })
|
||
const bobCookie = r.setCookie.split(';')[0]
|
||
|
||
r = await json('/api/dsh/launch', { method: 'POST', cookie, body: { folder: 'proj' } })
|
||
console.log('launch ->', r.status, r.body?.url)
|
||
assert(r.status === 200, 'launch succeeds')
|
||
// 2026-09-15(T08 S3):夹具 `fake-dsh.mjs` 现在**照实打印带 token 的 URL**(与真实 dsh 一致),
|
||
// 于是这里不能再写死成不带 token 的相等 —— 原断言是"夹具不吐 token"时的意外产物。
|
||
// 保留原意(是子域 URL、不泄露回环端口),并把 token 的存在一并纳入判据。
|
||
assert(r.body.url.startsWith('https://carol.test.local/'), 'launch returns the subdomain URL')
|
||
assert(!r.body.url.includes('127.0.0.1'), 'launch URL must not leak the loopback port')
|
||
|
||
await sleep(200)
|
||
|
||
let res = await getWithHost('/hello', 'carol.test.local', cookie)
|
||
console.log('authed /hello ->', res.status)
|
||
assert(res.status === 200 && res.body.includes('fake-dsh'), 'authed subdomain routes to the DSH')
|
||
|
||
res = await getWithHost('/hello', 'carol.test.local')
|
||
console.log('no-cookie /hello ->', res.status)
|
||
assert(res.status === 401, 'unauthenticated subdomain is rejected')
|
||
|
||
res = await getWithHost('/hello', 'carol.test.local', bobCookie)
|
||
console.log('bob /hello ->', res.status)
|
||
assert(res.status === 403, 'wrong user is rejected')
|
||
|
||
res = await getWithHost('/', `127.0.0.1:${port}`)
|
||
assert(!res.body.includes('fake-dsh'), 'non-subdomain Host does not proxy')
|
||
|
||
console.log('OK: subdomain routing + auth flow passed')
|
||
} finally {
|
||
await app.close()
|
||
await sleep(300)
|
||
try {
|
||
rmSync(dataRoot, { recursive: true, force: true })
|
||
} catch {
|
||
// best-effort cleanup
|
||
}
|
||
}
|