dsh-remote-agent 0.0.0-stage → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +700 -0
- package/CONTRIBUTING.md +358 -0
- package/KNOWN-ISSUES.md +601 -0
- package/LICENSE +21 -0
- package/README.en.md +353 -0
- package/README.md +317 -2
- package/SECURITY.md +348 -0
- package/bin/dsh-remote-relay.js +257 -0
- package/bin/dsh-remote-setup.js +440 -0
- package/bin/setup.cmd +30 -0
- package/cordis.patch.yml +87 -0
- package/deploy/relay-vps/Caddyfile +24 -0
- package/deploy/relay-vps/README.md +132 -0
- package/deploy/relay-vps/dsh-remote-relay.service +47 -0
- package/deploy/relay-vps/nginx.conf +49 -0
- package/deploy/relay-vps/relay.env.example +20 -0
- package/docs/dispatch-automation-eval.md +140 -0
- package/docs/screenshots/panel-main.png +0 -0
- package/docs/screenshots/panel-task-detail.png +0 -0
- package/docs/verification-agent-e2e.md +425 -0
- package/docs/verification-repos-routing-real.md +521 -0
- package/docs/verification-resume-real.md +254 -0
- package/docs/worker-setup-pitfalls.md +266 -0
- package/lib/artifacts.js +451 -0
- package/lib/client.js +1752 -0
- package/lib/e2e.js +304 -0
- package/lib/glob.js +225 -0
- package/lib/hub/audit-log.js +161 -0
- package/lib/hub/hub-util.js +114 -0
- package/lib/hub/index.js +500 -0
- package/lib/hub/node-registry.js +943 -0
- package/lib/hub/pull-manager.js +781 -0
- package/lib/hub/registry-handshake.js +403 -0
- package/lib/hub/registry-ingest.js +346 -0
- package/lib/hub/relay-link.js +228 -0
- package/lib/hub/subagent-provider.js +424 -0
- package/lib/hub/templates.js +693 -0
- package/lib/hub/tools.js +1059 -0
- package/lib/hub/ui-routes.js +856 -0
- package/lib/hub/views.js +351 -0
- package/lib/identity.js +271 -0
- package/lib/index.js +272 -0
- package/lib/node/artifact-service.js +231 -0
- package/lib/node/diff.js +124 -0
- package/lib/node/exec.js +536 -0
- package/lib/node/hub-pin.js +71 -0
- package/lib/node/index.js +1022 -0
- package/lib/node/repos.js +134 -0
- package/lib/node/resource.js +63 -0
- package/lib/node/runner.js +792 -0
- package/lib/node/task-engine.js +712 -0
- package/lib/node/task-store.js +273 -0
- package/lib/node/transport.js +763 -0
- package/lib/pairing.js +186 -0
- package/lib/protocol.js +414 -0
- package/lib/relay-config.js +317 -0
- package/lib/relay.js +732 -0
- package/lib/setup-routes.js +313 -0
- package/lib/setup.js +522 -0
- package/lib/ws.js +466 -0
- package/package.json +103 -4
- package/profiles/README.md +573 -0
- package/profiles/hub.cordis.yml +84 -0
- package/profiles/worker.cordis.yml +60 -0
- package/scripts/check-doc-drift.mjs +802 -0
- package/scripts/check-packaging.mjs +177 -0
- package/scripts/diag-inject.mjs +243 -0
- package/scripts/e2e-live-check.mjs +181 -0
- package/scripts/relay-init.ps1 +286 -0
- package/scripts/relay-init.sh +199 -0
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* relay CLI 的配置层(零外部依赖)。
|
|
3
|
+
*
|
|
4
|
+
* 职责边界:
|
|
5
|
+
* - 只做「参数解析 + 凭证落盘 + 输出格式化」三件事,不碰网络、不起服务。
|
|
6
|
+
* - 明文 token 只允许出现在 stdout 的显式打印路径上;日志/ready 行一律用指纹。
|
|
7
|
+
*
|
|
8
|
+
* 凭证文件:<data-dir>/tokens.json
|
|
9
|
+
* { "version": 1, "hubToken": "rt_...", "enrollToken": "en_...", "createdAt": "..." }
|
|
10
|
+
* POSIX 上 chmod 0600;Windows 上 chmod 无意义,改用 icacls 收窄到当前用户(best effort)。
|
|
11
|
+
* 已存在但内容损坏时**不静默轮换**(静默轮换会让已发出去的副机全部掉线),而是抛可读错误。
|
|
12
|
+
*/
|
|
13
|
+
import { spawnSync } from 'node:child_process'
|
|
14
|
+
import fs from 'node:fs'
|
|
15
|
+
import os from 'node:os'
|
|
16
|
+
import path from 'node:path'
|
|
17
|
+
import { newRelayToken, tokenFingerprint } from './relay.js'
|
|
18
|
+
|
|
19
|
+
export const DEFAULT_PORT = 8890
|
|
20
|
+
export const DEFAULT_HOST = '0.0.0.0'
|
|
21
|
+
export const TOKENS_FILE = 'tokens.json'
|
|
22
|
+
export const TOKENS_FILE_VERSION = 1
|
|
23
|
+
|
|
24
|
+
/** 默认凭证目录:~/.dsh-remote-agent-relay */
|
|
25
|
+
export function defaultDataDir() {
|
|
26
|
+
return path.join(os.homedir(), '.dsh-remote-agent-relay')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 收窄文件权限到「只有本人可读写」。
|
|
31
|
+
* POSIX:chmod 0600(目录 0700)。
|
|
32
|
+
* Windows:chmod 是空操作,用 icacls 去掉继承并只授权当前用户;失败只忽略,不影响主流程。
|
|
33
|
+
*/
|
|
34
|
+
export function restrictPermissions(target, { isDir = false } = {}) {
|
|
35
|
+
try {
|
|
36
|
+
fs.chmodSync(target, isDir ? 0o700 : 0o600)
|
|
37
|
+
} catch {
|
|
38
|
+
/* 某些文件系统(FAT/网络盘)不支持 mode,忽略 */
|
|
39
|
+
}
|
|
40
|
+
if (process.platform !== 'win32') return
|
|
41
|
+
|
|
42
|
+
const user = process.env.USERDOMAIN && process.env.USERNAME
|
|
43
|
+
? `${process.env.USERDOMAIN}\\${process.env.USERNAME}`
|
|
44
|
+
: process.env.USERNAME
|
|
45
|
+
if (!user) return
|
|
46
|
+
try {
|
|
47
|
+
spawnSync(
|
|
48
|
+
'icacls',
|
|
49
|
+
[target, '/inheritance:r', '/grant:r', isDir ? `${user}:(OI)(CI)F` : `${user}:F`],
|
|
50
|
+
{ stdio: 'ignore', timeout: 5000, windowsHide: true },
|
|
51
|
+
)
|
|
52
|
+
} catch {
|
|
53
|
+
/* icacls 不存在或被策略拦住:保持默认 ACL */
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 读 tokens.json;不存在则生成并写入(0600)。
|
|
59
|
+
* @param {string} [dataDir] 凭证目录,默认 ~/.dsh-remote-agent-relay
|
|
60
|
+
* @returns {{ hubToken: string, enrollToken: string, created: boolean, path: string }}
|
|
61
|
+
*/
|
|
62
|
+
export function loadOrCreateTokens(dataDir = defaultDataDir()) {
|
|
63
|
+
if (typeof dataDir !== 'string' || dataDir.trim() === '') {
|
|
64
|
+
throw new Error('loadOrCreateTokens: dataDir 必须是非空字符串')
|
|
65
|
+
}
|
|
66
|
+
const dir = path.resolve(dataDir)
|
|
67
|
+
const file = path.join(dir, TOKENS_FILE)
|
|
68
|
+
|
|
69
|
+
if (fs.existsSync(file)) {
|
|
70
|
+
let parsed
|
|
71
|
+
try {
|
|
72
|
+
parsed = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
73
|
+
} catch (err) {
|
|
74
|
+
throw new Error(`凭证文件不是合法 JSON:${file}(${err.message})。请修复或删除后重跑以重新生成。`, { cause: err })
|
|
75
|
+
}
|
|
76
|
+
const hubToken = typeof parsed?.hubToken === 'string' ? parsed.hubToken.trim() : ''
|
|
77
|
+
const enrollToken = typeof parsed?.enrollToken === 'string' ? parsed.enrollToken.trim() : ''
|
|
78
|
+
if (hubToken === '' || enrollToken === '') {
|
|
79
|
+
throw new Error(`凭证文件缺少 hubToken / enrollToken:${file}。请修复或删除后重跑以重新生成。`)
|
|
80
|
+
}
|
|
81
|
+
restrictPermissions(file)
|
|
82
|
+
return { hubToken, enrollToken, created: false, path: file }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const hubToken = newRelayToken('rt')
|
|
86
|
+
const enrollToken = newRelayToken('en')
|
|
87
|
+
const body = `${JSON.stringify(
|
|
88
|
+
{ version: TOKENS_FILE_VERSION, hubToken, enrollToken, createdAt: new Date().toISOString() },
|
|
89
|
+
null,
|
|
90
|
+
2,
|
|
91
|
+
)}\n`
|
|
92
|
+
|
|
93
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
94
|
+
restrictPermissions(dir, { isDir: true })
|
|
95
|
+
const tmp = `${file}.${process.pid}.tmp`
|
|
96
|
+
try {
|
|
97
|
+
fs.writeFileSync(tmp, body, { mode: 0o600 })
|
|
98
|
+
restrictPermissions(tmp)
|
|
99
|
+
fs.renameSync(tmp, file)
|
|
100
|
+
} catch (err) {
|
|
101
|
+
try {
|
|
102
|
+
fs.rmSync(tmp, { force: true })
|
|
103
|
+
} catch {
|
|
104
|
+
/* 清理失败不影响报错 */
|
|
105
|
+
}
|
|
106
|
+
throw new Error(`写入凭证文件失败:${file}(${err.message})`, { cause: err })
|
|
107
|
+
}
|
|
108
|
+
restrictPermissions(file)
|
|
109
|
+
return { hubToken, enrollToken, created: true, path: file }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function parsePortValue(raw, flag) {
|
|
113
|
+
const text = String(raw).trim()
|
|
114
|
+
if (!/^\d+$/.test(text)) {
|
|
115
|
+
throw new Error(`${flag} 需要 0-65535 的整数端口,收到 "${raw}"`)
|
|
116
|
+
}
|
|
117
|
+
const n = Number.parseInt(text, 10)
|
|
118
|
+
if (!Number.isInteger(n) || n < 0 || n > 65535) {
|
|
119
|
+
throw new Error(`${flag} 需要 0-65535 的整数端口,收到 "${raw}"(0 表示随机端口)`)
|
|
120
|
+
}
|
|
121
|
+
return n
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function parseHostValue(raw, flag) {
|
|
125
|
+
const text = String(raw).trim()
|
|
126
|
+
if (text === '') throw new Error(`${flag} 需要非空的监听地址,例如 0.0.0.0 或 127.0.0.1`)
|
|
127
|
+
if (/\s/.test(text)) throw new Error(`${flag} 里不能有空白字符,收到 "${raw}"`)
|
|
128
|
+
return text
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* 布尔开关的取值。
|
|
133
|
+
*
|
|
134
|
+
* 为什么需要:`--print-tokens=false` / `--help=no` 这种写法里,`=value` 部分
|
|
135
|
+
* 此前被**完全忽略**,一律当成"开关被打开"。对 `--print-tokens` 来说,
|
|
136
|
+
* 这个静默方向恰好是最敏感的那一边 —— 用户显式写了 `=false`(不想打印明文
|
|
137
|
+
* token),程序却照样把明文打到 stdout。要么按值执行,要么明确拒绝,
|
|
138
|
+
* 绝不能默默反转。
|
|
139
|
+
*/
|
|
140
|
+
function parseBoolValue(inline, flag) {
|
|
141
|
+
if (inline === null) return true
|
|
142
|
+
const t = String(inline).trim().toLowerCase()
|
|
143
|
+
if (t === '' || t === 'true' || t === '1' || t === 'yes' || t === 'on') return true
|
|
144
|
+
if (t === 'false' || t === '0' || t === 'no' || t === 'off') return false
|
|
145
|
+
throw new Error(`${flag} 只接受 true/false(或 1/0、yes/no、on/off),收到 "${inline}"`)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function envPort(env) {
|
|
149
|
+
const raw = env?.DSH_RELAY_PORT
|
|
150
|
+
if (raw === undefined || raw === null || String(raw).trim() === '') return DEFAULT_PORT
|
|
151
|
+
try {
|
|
152
|
+
return parsePortValue(raw, 'DSH_RELAY_PORT')
|
|
153
|
+
} catch (err) {
|
|
154
|
+
throw new Error(`环境变量 DSH_RELAY_PORT 非法:${err.message}`, { cause: err })
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function envText(env, name, fallback) {
|
|
159
|
+
const raw = env?.[name]
|
|
160
|
+
if (raw === undefined || raw === null || String(raw).trim() === '') return fallback
|
|
161
|
+
return String(raw).trim()
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* 解析 CLI 参数,带环境变量回退。非法参数抛带可读信息的 Error(调用方打 stderr + exit 2)。
|
|
166
|
+
*
|
|
167
|
+
* 优先级:**CLI > 环境变量 > 默认值**。这条必须靠"按需回退"实现,不能在函数开头
|
|
168
|
+
* 就把 env 全部求值一遍 —— 历史缺陷正是后者:`port` / `host` 在解析 argv **之前**
|
|
169
|
+
* 无条件从 env 读取并校验,于是一个陈旧的 `DSH_RELAY_PORT=abc`(或
|
|
170
|
+
* `DSH_RELAY_HOST='a b'`)会让 `--port 1234 --host 127.0.0.1` 这样完全正确的
|
|
171
|
+
* 调用也直接抛错退出 —— 用户明明显式给了值,却因为一个被覆盖的环境变量起不来。
|
|
172
|
+
* 现在 env 只在"命令行确实没给这个键"时才被读取和校验。
|
|
173
|
+
*
|
|
174
|
+
* @param {string[]} argv 不含 node 与脚本名
|
|
175
|
+
* @param {Record<string,string|undefined>} [env]
|
|
176
|
+
* @returns {{ port:number, host:string, hubToken:string, enrollToken:string, dataDir:string, printTokens:boolean, help:boolean }}
|
|
177
|
+
*/
|
|
178
|
+
export function parseArgs(argv = [], env = process.env) {
|
|
179
|
+
// undefined = 命令行没给;由 env 回退填充。CLI 给过的键永不被 env 覆盖。
|
|
180
|
+
const out = {
|
|
181
|
+
port: undefined,
|
|
182
|
+
host: undefined,
|
|
183
|
+
hubToken: undefined,
|
|
184
|
+
enrollToken: undefined,
|
|
185
|
+
dataDir: undefined,
|
|
186
|
+
printTokens: false,
|
|
187
|
+
help: false,
|
|
188
|
+
}
|
|
189
|
+
const list = Array.isArray(argv) ? argv : []
|
|
190
|
+
|
|
191
|
+
for (let i = 0; i < list.length; i += 1) {
|
|
192
|
+
const raw = String(list[i])
|
|
193
|
+
if (raw === '') throw new Error('收到空参数(检查脚本里的变量展开是否为空)')
|
|
194
|
+
|
|
195
|
+
let key = raw
|
|
196
|
+
let inline = null
|
|
197
|
+
const eq = raw.indexOf('=')
|
|
198
|
+
if (raw.startsWith('--') && eq > 2) {
|
|
199
|
+
key = raw.slice(0, eq)
|
|
200
|
+
inline = raw.slice(eq + 1)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (key === '--help' || raw === '-h') {
|
|
204
|
+
out.help = parseBoolValue(inline, raw === '-h' ? '-h' : '--help')
|
|
205
|
+
continue
|
|
206
|
+
}
|
|
207
|
+
if (key === '--print-tokens') {
|
|
208
|
+
out.printTokens = parseBoolValue(inline, key)
|
|
209
|
+
continue
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const need = () => {
|
|
213
|
+
if (inline !== null) return inline
|
|
214
|
+
const next = list[i + 1]
|
|
215
|
+
if (next === undefined || String(next).startsWith('--')) {
|
|
216
|
+
throw new Error(`${key} 需要一个取值(写法:${key}=VALUE)`)
|
|
217
|
+
}
|
|
218
|
+
i += 1
|
|
219
|
+
return String(next)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
switch (key) {
|
|
223
|
+
case '--port':
|
|
224
|
+
out.port = parsePortValue(need(), '--port')
|
|
225
|
+
break
|
|
226
|
+
case '--host':
|
|
227
|
+
out.host = parseHostValue(need(), '--host')
|
|
228
|
+
break
|
|
229
|
+
case '--hub-token': {
|
|
230
|
+
const v = need().trim()
|
|
231
|
+
if (v === '') throw new Error('--hub-token 不能为空字符串')
|
|
232
|
+
out.hubToken = v
|
|
233
|
+
break
|
|
234
|
+
}
|
|
235
|
+
case '--enroll-token': {
|
|
236
|
+
const v = need().trim()
|
|
237
|
+
if (v === '') throw new Error('--enroll-token 不能为空字符串')
|
|
238
|
+
out.enrollToken = v
|
|
239
|
+
break
|
|
240
|
+
}
|
|
241
|
+
case '--data-dir': {
|
|
242
|
+
const v = need().trim()
|
|
243
|
+
if (v === '') throw new Error('--data-dir 不能为空字符串')
|
|
244
|
+
out.dataDir = v
|
|
245
|
+
break
|
|
246
|
+
}
|
|
247
|
+
default:
|
|
248
|
+
if (raw.startsWith('-')) throw new Error(`未知参数:${raw}(用 --help 看用法)`)
|
|
249
|
+
throw new Error(`不支持的位置参数:${raw}(所有配置都走 --key=value)`)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/* 环境变量回退:只填命令行没给过的键 ------------------------------- */
|
|
254
|
+
if (out.port === undefined) out.port = envPort(env)
|
|
255
|
+
if (out.host === undefined) out.host = envText(env, 'DSH_RELAY_HOST', DEFAULT_HOST)
|
|
256
|
+
if (out.hubToken === undefined) out.hubToken = envText(env, 'DSH_RELAY_HUB_TOKEN', '')
|
|
257
|
+
if (out.enrollToken === undefined) out.enrollToken = envText(env, 'DSH_RELAY_ENROLL_TOKEN', '')
|
|
258
|
+
if (out.dataDir === undefined) out.dataDir = envText(env, 'DSH_RELAY_DATA_DIR', defaultDataDir())
|
|
259
|
+
|
|
260
|
+
// 参数化后再兜一次:--host 可能来自环境变量
|
|
261
|
+
out.host = parseHostValue(out.host, '--host / DSH_RELAY_HOST')
|
|
262
|
+
return out
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* 机器可读的 ready 行。**只放指纹,不放明文 token。**
|
|
267
|
+
* @param {{url:string, hubFingerprint:string, enrollFingerprint:string}} o
|
|
268
|
+
*/
|
|
269
|
+
export function formatReadyLine(o = {}) {
|
|
270
|
+
const { url, hubFingerprint, enrollFingerprint } = o
|
|
271
|
+
if (typeof url !== 'string' || url === '') throw new Error('formatReadyLine: url 必填')
|
|
272
|
+
if (typeof hubFingerprint !== 'string' || hubFingerprint === '') throw new Error('formatReadyLine: hubFingerprint 必填')
|
|
273
|
+
if (typeof enrollFingerprint !== 'string' || enrollFingerprint === '') throw new Error('formatReadyLine: enrollFingerprint 必填')
|
|
274
|
+
return `RELAY_READY url=${url} hub=${hubFingerprint} enroll=${enrollFingerprint}`
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* 明文 token 行。只给 `--print-tokens` / 首次生成 / 交互式终端这三条路径用,
|
|
279
|
+
* 绝不能进任何持久日志。
|
|
280
|
+
*/
|
|
281
|
+
export function formatTokensLine({ hubToken, enrollToken, source, file } = {}) {
|
|
282
|
+
if (typeof hubToken !== 'string' || hubToken === '') throw new Error('formatTokensLine: hubToken 必填')
|
|
283
|
+
if (typeof enrollToken !== 'string' || enrollToken === '') throw new Error('formatTokensLine: enrollToken 必填')
|
|
284
|
+
const tail = [
|
|
285
|
+
source ? `source=${source}` : '',
|
|
286
|
+
file ? `file=${file}` : '',
|
|
287
|
+
].filter(Boolean).join(' ')
|
|
288
|
+
return `RELAY_TOKENS hub=${hubToken} enroll=${enrollToken}${tail ? ` ${tail}` : ''}`
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* 只有指纹的凭证行(不泄露明文,可安全进日志)。
|
|
293
|
+
*
|
|
294
|
+
* 与 formatReadyLine / formatTokensLine 保持一致:缺少凭证时**报错**,不静默。
|
|
295
|
+
* 历史缺陷:这里对缺失/空 token 直接 `tokenFingerprint(hubToken ?? '')`,
|
|
296
|
+
* 于是 `{}` 或 `{hubToken:'', enrollToken:''}` 会输出
|
|
297
|
+
* `RELAY_TOKENS_SOURCE hub=e3b0c44298fc enroll=e3b0c44298fc` ——
|
|
298
|
+
* 这是空串的 sha256 前缀,**看起来和一个真实指纹毫无区别**。
|
|
299
|
+
* 非交互式路径(systemd/journald)默认只打这一行,一旦凭证真的没取到,
|
|
300
|
+
* 日志里留下的是一对"看起来正常"的假指纹,事后排查会以为 token 存在。
|
|
301
|
+
* 三个格式化函数对同一个输入必须给同一种态度:要么都抛,要么都静默。
|
|
302
|
+
*/
|
|
303
|
+
export function formatTokenFingerprintLine({ hubToken, enrollToken, source, file } = {}) {
|
|
304
|
+
if (typeof hubToken !== 'string' || hubToken === '') {
|
|
305
|
+
throw new Error('formatTokenFingerprintLine: hubToken 必填')
|
|
306
|
+
}
|
|
307
|
+
if (typeof enrollToken !== 'string' || enrollToken === '') {
|
|
308
|
+
throw new Error('formatTokenFingerprintLine: enrollToken 必填')
|
|
309
|
+
}
|
|
310
|
+
return [
|
|
311
|
+
'RELAY_TOKENS_SOURCE',
|
|
312
|
+
source ? `source=${source}` : '',
|
|
313
|
+
file ? `file=${file}` : '',
|
|
314
|
+
`hub=${tokenFingerprint(hubToken)}`,
|
|
315
|
+
`enroll=${tokenFingerprint(enrollToken)}`,
|
|
316
|
+
].filter(Boolean).join(' ')
|
|
317
|
+
}
|