device-center 0.2.0-beta.4 → 0.2.0-beta.6
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/CONTRIBUTING.md +1 -1
- package/README.md +25 -8
- package/SECURITY.md +14 -2
- package/bin/device-center.mjs +29 -5
- package/docs/agent-install.md +5 -5
- package/docs/cli.md +97 -0
- package/docs/driver-binding.md +5 -5
- package/docs/friend-trial.md +5 -5
- package/docs/hosted-access.md +1 -1
- package/docs/npm.md +12 -10
- package/docs/onboarding.md +4 -4
- package/docs/remote-access.md +2 -2
- package/docs/roadmap.md +13 -1
- package/docs/security-and-sync.md +2 -0
- package/docs/troubleshooting.md +11 -0
- package/docs/windows-acceptance.md +57 -0
- package/login.html +1 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +4 -2
- package/scripts/access.mjs +0 -1
- package/scripts/driver.mjs +2 -3
- package/scripts/service.mjs +42 -16
- package/server/cli/commands.mjs +181 -0
- package/server/cloudAccess.mjs +1 -1
- package/server/drivers/access.mjs +15 -14
- package/server/drivers/browserPairing.mjs +9 -4
- package/server/drivers/cliClient.mjs +87 -0
- package/server/drivers/cliManagement.mjs +119 -0
- package/server/drivers/cloudLink.mjs +19 -20
- package/server/drivers/cloudRegistry.mjs +6 -0
- package/server/drivers/privateFiles.mjs +57 -0
- package/server/drivers/windowsSecurity.mjs +119 -0
- package/server/drivers/windowsService.mjs +77 -0
- package/server/index.mjs +36 -8
- package/server/unifiedAccess.mjs +24 -4
- package/src/api.js +2 -0
- package/src/cli-approval.js +49 -0
- package/src/cli-handoff.js +19 -0
- package/src/install-prompt.js +2 -2
- package/src/install-walkthrough.js +6 -6
- package/src/login-flow.js +37 -0
- package/src/login.css +3 -1
- package/src/login.js +15 -10
- package/src/main.js +7 -6
- package/src/messages.js +359 -117
- package/src/onboarding.js +35 -15
- package/src/pairing-handoff.js +6 -2
- package/src/start.js +10 -2
- package/src/styles.css +1 -0
- package/start.html +12 -4
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Windows 11 手动接入验收
|
|
2
|
+
|
|
3
|
+
**0.2.0-beta.6 · Windows 11 预览版。** 安装前可用 `npm.cmd view device-center@0.2.0-beta.6 version` 确认发布状态。Windows CI 的脚本、安全存储及安装检查不能替代你的 Windows 11 实机验收。
|
|
4
|
+
|
|
5
|
+
## 准备与安装
|
|
6
|
+
|
|
7
|
+
需要 Windows 11、当前普通用户、Windows PowerShell 5.1+、官方 [Node.js 24+](https://nodejs.org/)(含 npm),能访问 npm registry 和选定 HTTPS 控制面。无需 AI Agent、MCP、AD 域或管理员窗口;不使用 WSL。
|
|
8
|
+
|
|
9
|
+
在**目标 Windows 电脑**打开[手动安装指南](https://devices.owenshen.top/start?lang=zh-CN&method=manual&mode=official&os=windows#install-title),选择官方托管、Windows 11、手动安装,再复制 PowerShell 脚本。不需要下载 ZIP,也不需要目标电脑有 AI Agent。
|
|
10
|
+
|
|
11
|
+
```text
|
|
12
|
+
在目标 Windows 电脑打开手动安装指南、复制脚本
|
|
13
|
+
→ 开始菜单搜索 PowerShell,点“打开”(不用管理员)
|
|
14
|
+
→ 粘贴到 PowerShell,回车
|
|
15
|
+
→ 浏览器自动打开本次绑定申请
|
|
16
|
+
→ 登录自己的账号,对照终端完整指纹,批准一次
|
|
17
|
+
→ 终端自动确认首次资源回报;无需回 Agent 说“已批准”
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
选择“本机使用”只安装本机面板,不申请云端绑定。已有 CLI、服务配置、任务或占用端口时停止,保留原状态;不要反复运行安装脚本。缺少 Node.js 时从官网安装后重开 PowerShell。安装需要联网访问 npm;复制文本执行不需要修改 PowerShell 执行策略。
|
|
21
|
+
|
|
22
|
+
**电脑在国内公司、不在身边:** 把上述指南发给能操作它的同事。可选“无浏览器/服务器”,让同事在那台 Windows 的 PowerShell 执行脚本,保持窗口打开;你在自己的电脑登录官方控制面,输入终端配对码,核对完整指纹并批准。代码只在实际运行时生成,不预放在脚本里,也不要发给 AI Agent。目标电脑仍需出站 HTTPS、npm 连通;不要求两台电脑在同一局域网或开放入站端口。
|
|
23
|
+
|
|
24
|
+
## 验收清单
|
|
25
|
+
|
|
26
|
+
1. `--version` 是 `0.2.0-beta.6`;后台状态显示 `windows-task`,本机面板 `http://127.0.0.1:5174` 正常加载,页面脚本无 404。
|
|
27
|
+
2. 浏览器只批准刚由本机发起的申请;完整指纹与终端一致。终端有“云端已接收首次资源回报”,`firstReport.verified=true`。仅 `configured=true` 不算连通。
|
|
28
|
+
3. 云端出现这台 Windows 电脑,内存和用户目录所在卷磁盘分别显示真实数值;没有测试设备或伪造在线。可填写设备名、用途与标签。
|
|
29
|
+
4. 点击云端“采集一次”,采集时间在约 30–75 秒内更新。关闭安装终端后仍更新;注销/休眠会停止回报,登录/唤醒且网络恢复后继续。
|
|
30
|
+
5. 在 Windows 任务计划程序中查看 `DeviceCenter-…`:当前用户、用户登录时触发、非最高权限。重启并登录后不重新安装也能恢复;长期停在登录界面不会运行。
|
|
31
|
+
6. 本机身份文件在 `%USERPROFILE%\AppData\Local\Device Center\driver-link.json`,只存 DPAPI 密文,目录访问限当前用户与 SYSTEM。**不要打开或分享私钥,也不要把身份文件搬到其他电脑/用户。** DPAPI 不防同账号恶意程序或管理员控制。
|
|
32
|
+
7. 不接 AI Agent 也可使用设备回报。需要管理端 AI 时,在有 Agent 的管理电脑添加它自己的 loopback `/mcp`;目标 Windows 不需要 MCP 客户端。
|
|
33
|
+
|
|
34
|
+
## 状态与排查
|
|
35
|
+
|
|
36
|
+
在普通用户 PowerShell 执行:
|
|
37
|
+
|
|
38
|
+
```powershell
|
|
39
|
+
& "$HOME\AppData\Local\Programs\Device Center\cli\device-center.cmd" --version
|
|
40
|
+
& "$HOME\AppData\Local\Programs\Device Center\cli\device-center.cmd" status
|
|
41
|
+
& "$HOME\AppData\Local\Programs\Device Center\cli\device-center.cmd" service status --port 5174
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
- **打不开网页**:核对 Node 版本、5174 是否被占用、服务状态;不停止别的程序。若选择另一端口,要让服务与 connect 使用同一端口。
|
|
45
|
+
- **任务被策略阻止**:保留错误。可显式选择 `start --port 5174` 前台运行并保持终端;不改组策略、执行策略或防火墙。
|
|
46
|
+
- **安全存储失败**:确认当前用户、用户目录在支持 NTFS ACL 的本地卷、系统 PowerShell 可用;目录被共享或变为链接时程序会拒绝。不要把权限放宽到 Everyone 来解决。
|
|
47
|
+
- **绑定成功但没有回报**:检查电脑唤醒、网络、系统时间、HTTPS 可达与后台日志。不要重复绑定;没有入站端口要求。
|
|
48
|
+
- **未回报/待刷新**:表示近期没收到状态,不是设备身份私钥过期。睡眠、注销、断网时无法采集。
|
|
49
|
+
- **后续读 Windows 日志**:需两端核对指纹并单独 `access trust` / `access grant`。仅允许显式本地盘文件,例如 `C:\Logs\app.log`,不接受 UNC 共享或备用数据流。绑定不开放任意命令,也不授予日志权限。本轮先验收安装/配对/后台,真实跨平台日志再独立确认。
|
|
50
|
+
|
|
51
|
+
需要停止并卸载本次后台任务时,由用户明确执行,默认保留数据和身份:
|
|
52
|
+
|
|
53
|
+
```powershell
|
|
54
|
+
& "$HOME\AppData\Local\Programs\Device Center\cli\device-center.cmd" service uninstall --port 5174 --confirm
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
卸载仅处理与当前计划完全一致的任务;不能确认监听已停止时保留配置并报错,不杀其他程序。反馈只需系统版本、CLI 版本、失败步骤和已去敏的错误;不附账号凭据、配对码、身份文件或日志正文。
|
package/login.html
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<!doctype html>
|
|
2
2
|
<html lang="zh-CN">
|
|
3
3
|
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="icon" href="/favicon.svg" type="image/svg+xml"><title data-i18n="login.title">登录 · Device Center</title><link rel="stylesheet" href="/src/login.css"><link rel="stylesheet" href="/src/i18n.css"></head>
|
|
4
|
-
<body class="login-page"><main><div class="language-picker" data-language-picker></div><div class="mark">DC</div><h1>Device Center</h1><p data-i18n="login.subtitle">登录你的设备控制面</p><p id="loading" role="status" data-i18n="login.loading">正在读取登录方式…</p><form id="login" hidden><label><span data-i18n="login.username">用户名</span><input name="username" autocomplete="username" required maxlength="64"></label><label><span data-i18n="login.password">密码</span><input name="password" type="password" autocomplete="current-password" required maxlength="256"></label><button type="submit" data-i18n="login.submit">登录</button><p id="message" role="alert"></p></form><section id="unified" hidden><p data-i18n="login.unified">使用 App Services/个人站的统一账号。</p><
|
|
4
|
+
<body class="login-page"><main><div class="language-picker" data-language-picker></div><div class="mark">DC</div><h1>Device Center</h1><p data-i18n="login.subtitle">登录你的设备控制面</p><p id="loading" role="status" data-i18n="login.loading">正在读取登录方式…</p><form id="login" hidden><label><span data-i18n="login.username">用户名</span><input name="username" autocomplete="username" required maxlength="64"></label><label><span data-i18n="login.password">密码</span><input name="password" type="password" autocomplete="current-password" required maxlength="256"></label><button type="submit" data-i18n="login.submit">登录</button><p id="message" role="alert"></p></form><section id="unified" hidden><p data-i18n="login.unified">使用 App Services/个人站的统一账号。</p><button id="continue-login" type="button" data-i18n="login.continue">进入控制面</button><p id="unified-message" role="status" aria-live="polite"></p><p class="help" data-i18n="login.help">已有登录会自动进入。需要登录时打开下方账号页,完成后回到这里继续。</p><a id="unified-login" class="login-link" target="_blank" rel="noopener noreferrer" data-i18n="login.link">使用统一账号登录 ↗</a></section><p class="install-help"><span data-i18n="login.notInstalled">还未安装?</span><a href="/start" target="_blank" rel="noopener noreferrer" data-language-link data-i18n="login.guide">查看一页试用指南 ↗</a><br><span data-i18n="login.installHint">本机使用无需账号;官方托管需登录并核对绑定。</span></p><small id="access-notes" data-i18n="login.multi">账号设备隔离 · HTTPS 保护</small></main><script type="module" src="/src/login.js"></script></body>
|
|
5
5
|
</html>
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "device-center",
|
|
3
|
-
"version": "0.2.0-beta.
|
|
3
|
+
"version": "0.2.0-beta.6",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "device-center",
|
|
9
|
-
"version": "0.2.0-beta.
|
|
9
|
+
"version": "0.2.0-beta.6",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@peculiar/x509": "2.1.0",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "device-center",
|
|
3
|
-
"version": "0.2.0-beta.
|
|
3
|
+
"version": "0.2.0-beta.6",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Local device connector with account-bound pairing, TLS encrypted relay and explicitly authorized log access",
|
|
@@ -34,12 +34,14 @@
|
|
|
34
34
|
"SECURITY.md",
|
|
35
35
|
"LICENSE",
|
|
36
36
|
"scripts/access.mjs",
|
|
37
|
+
"docs/cli.md",
|
|
37
38
|
"docs/remote-access.md",
|
|
38
39
|
"npm-shrinkwrap.json",
|
|
39
40
|
"start.html",
|
|
40
41
|
"favicon.svg",
|
|
41
42
|
"docs/friend-trial.md",
|
|
42
|
-
"docs/i18n.md"
|
|
43
|
+
"docs/i18n.md",
|
|
44
|
+
"docs/windows-acceptance.md"
|
|
43
45
|
],
|
|
44
46
|
"publishConfig": {
|
|
45
47
|
"access": "public",
|
package/scripts/access.mjs
CHANGED
|
@@ -21,7 +21,6 @@ export async function main(argv = []) {
|
|
|
21
21
|
const allowed={peers:[],status:[],enable:['confirm'],renew:['confirm'],trust:['peer','lan','port','confirm'],grant:['peer','log','label','minutes','confirm'],revoke:['peer','confirm'],listen:['bind','port','confirm'],'listen-off':['confirm'],read:['peer','label','bytes']}[action];
|
|
22
22
|
const options={};
|
|
23
23
|
for(let i=1;i<argv.length;i++){const key=argv[i].slice(2);if(!argv[i].startsWith('--')||!allowed.includes(key)||Object.hasOwn(options,key))throw new Error(usage);if(key==='confirm')options[key]=true;else{if(!argv[i+1]||argv[i+1].startsWith('--'))throw new Error(usage);options[key]=argv[++i];}}
|
|
24
|
-
if(process.platform==='win32')throw new Error('Windows 原生私钥权限与服务尚未验收;本版支持 macOS/Linux。');
|
|
25
24
|
const file=defaultLinkFile(),local=accessFile(file);
|
|
26
25
|
if(action==='status'){const a=readAccess(local);return {trusts:a.trusts.map(({certificate,...p})=>p),grants:a.grants.map(({certificate,...p})=>p),listen:a.listen};}
|
|
27
26
|
const state=readLink(file);if(!state?.driverId||state.disabled)throw new Error('先 connect 并在云端核对批准。');
|
package/scripts/driver.mjs
CHANGED
|
@@ -24,7 +24,7 @@ export function driverPlan(argv = []) {
|
|
|
24
24
|
}
|
|
25
25
|
if (!['plan', 'connect', 'run', 'status', 'disconnect'].includes(command) || !Number.isInteger(port) || port < 1024 || port > 65535) throw new Error(usage);
|
|
26
26
|
cloudOrigin(origin);
|
|
27
|
-
return { command, origin, port, confirm, shareSsh, browser, fields: ['管理电脑名称', '系统类型', '内存', '用户目录卷磁盘', ...(shareSsh ? ['至多15个已关联SSH主机的名称和缓存资源状态(不含地址、用户、路径或配置)'] : [])], keyStorage: '仅本机私有目录/0600文件;当前不是Keychain/TPM封装', tasksAuthorized: false, backgroundInstalled: false };
|
|
27
|
+
return { command, origin, port, confirm, shareSsh, browser, fields: ['管理电脑名称', '系统类型', '内存', '用户目录卷磁盘', ...(shareSsh ? ['至多15个已关联SSH主机的名称和缓存资源状态(不含地址、用户、路径或配置)'] : [])], keyStorage: process.platform === 'win32' ? '本机 DPAPI CurrentUser 加密与当前用户/SYSTEM NTFS ACL;非 TPM,不防同账号恶意程序' : '仅本机私有目录/0600文件;当前不是Keychain/TPM封装', tasksAuthorized: false, backgroundInstalled: false };
|
|
28
28
|
}
|
|
29
29
|
export async function runLocal(plan) {
|
|
30
30
|
// Reuse only a running native Driver that implements this protocol; never stop another listener.
|
|
@@ -49,7 +49,6 @@ export async function main(argv = process.argv.slice(2), { file = defaultLinkFil
|
|
|
49
49
|
const plan = driverPlan(argv);
|
|
50
50
|
if (Number(process.versions.node.split('.')[0]) < 24) throw new Error('需要 Node.js 24 或更高版本。');
|
|
51
51
|
if (plan.command === 'plan') return plan; // No files, key generation, network or installation.
|
|
52
|
-
if (process.platform === 'win32') throw new Error('Windows 身份文件的安全存储尚未实现,当前管理 Driver 只支持 macOS/Linux。');
|
|
53
52
|
let state = readLink(file);
|
|
54
53
|
if (plan.command === 'status') return linkSummary(state);
|
|
55
54
|
if (plan.command === 'run') { if (!state?.driverId || state.disabled) throw new Error('请先 connect 完成配对。'); await runLocal(plan); return linkSummary(state); }
|
|
@@ -69,7 +68,7 @@ export async function main(argv = process.argv.slice(2), { file = defaultLinkFil
|
|
|
69
68
|
}
|
|
70
69
|
if (!state.certificate) { state.certificate = await issueCertificate(state); writeLink(file, state); }
|
|
71
70
|
let pending;
|
|
72
|
-
try { pending = await post(plan.origin, '/api/driver/pair/start', { publicKey: state.publicKey, certificate: state.certificate, name: hostname().slice(0,80), platform: ({ darwin: 'macOS', linux: 'Linux' })[platform()], shareSsh: plan.shareSsh, ...(plan.browser ? { browser: true } : {}) }); }
|
|
71
|
+
try { pending = await post(plan.origin, '/api/driver/pair/start', { publicKey: state.publicKey, certificate: state.certificate, name: hostname().slice(0,80), platform: ({ darwin: 'macOS', linux: 'Linux', win32: 'Windows' })[platform()] ?? platform(), shareSsh: plan.shareSsh, ...(plan.browser ? { browser: true } : {}) }); }
|
|
73
72
|
catch (error) {
|
|
74
73
|
if (plan.browser && error.statusCode === 400 && error.message === 'invalid_driver_input') throw new Error('控制面尚不支持浏览器接入,请升级控制面;或在本机终端使用不带 --browser 的 connect。未自动输出配对凭据。');
|
|
75
74
|
throw error;
|
package/scripts/service.mjs
CHANGED
|
@@ -3,25 +3,29 @@
|
|
|
3
3
|
// Default command is a plan; importing this module has no side effects.
|
|
4
4
|
import { mkdir, writeFile, unlink, lstat } from 'node:fs/promises';
|
|
5
5
|
import { homedir } from 'node:os';
|
|
6
|
-
import { dirname, join, resolve } from 'node:path';
|
|
6
|
+
import { dirname, join, resolve, win32 } from 'node:path';
|
|
7
7
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
8
8
|
import { spawnSync } from 'node:child_process';
|
|
9
|
+
import { defaultDataDirectory, ensurePrivateDirectory, writePrivateJson, readPrivateJson } from '../server/drivers/privateFiles.mjs';
|
|
10
|
+
import { windowsServiceDefinition, windowsTask } from '../server/drivers/windowsService.mjs';
|
|
9
11
|
|
|
10
12
|
const ROOT = resolve(fileURLToPath(new URL('..', import.meta.url)));
|
|
11
13
|
const LABEL = 'local.device-center.control-plane';
|
|
12
14
|
const xml = (v) => String(v).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
13
15
|
const unitQuote = (v) => `"${String(v).replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('%', '%%')}"`;
|
|
14
16
|
|
|
15
|
-
export function servicePlan({ platform = process.platform, home = homedir(), root = ROOT, node = process.execPath, port = 5173 } = {}) {
|
|
17
|
+
export function servicePlan({ platform = process.platform, home = homedir(), root = ROOT, node = process.execPath, port = 5173, environment = process.env } = {}) {
|
|
16
18
|
if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error('Choose a port from 1024 to 65535.');
|
|
17
19
|
if (![home, root, node].every((s) => typeof s === 'string' && !/[\r\n\0]/.test(s))) throw new Error('Invalid path.');
|
|
18
|
-
const supported = ['darwin', 'linux'].includes(platform);
|
|
19
|
-
const dataDirectory =
|
|
20
|
-
const
|
|
21
|
-
const
|
|
22
|
-
const
|
|
23
|
-
const
|
|
20
|
+
const supported = ['darwin', 'linux', 'win32'].includes(platform);
|
|
21
|
+
const dataDirectory = defaultDataDirectory(home, platform);
|
|
22
|
+
const pathJoin = platform === 'win32' ? win32.join : join;
|
|
23
|
+
const dbPath = pathJoin(dataDirectory, 'device-center.sqlite');
|
|
24
|
+
const script = pathJoin(root, 'server', 'index.mjs');
|
|
25
|
+
const manager = platform === 'darwin' ? 'launchd' : platform === 'linux' ? 'systemd' : platform === 'win32' ? 'windows-task' : 'unsupported';
|
|
26
|
+
const configPath = platform === 'darwin' ? join(home, 'Library', 'LaunchAgents', `${LABEL}.plist`) : platform === 'win32' ? pathJoin(dataDirectory, 'service.json') : join(home, '.config', 'systemd', 'user', 'device-center.service');
|
|
24
27
|
let configuration = null;
|
|
28
|
+
let task = null;
|
|
25
29
|
if (platform === 'darwin') {
|
|
26
30
|
configuration = `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict>
|
|
27
31
|
<key>Label</key><string>${LABEL}</string>
|
|
@@ -34,8 +38,11 @@ export function servicePlan({ platform = process.platform, home = homedir(), roo
|
|
|
34
38
|
</dict></plist>\n`;
|
|
35
39
|
} else if (platform === 'linux') {
|
|
36
40
|
configuration = `[Unit]\nDescription=Device Center local read-only connector\n[Service]\nType=simple\nWorkingDirectory=${unitQuote(root)}\nExecStart=${unitQuote(node)} ${unitQuote(script)}\nEnvironment="PORT=${port}"\nEnvironment=${unitQuote(`DEVICE_CENTER_DB_PATH=${dbPath}`)}\nEnvironment="DEVICE_CENTER_SERVICE_MANAGER=systemd"\nRestart=on-failure\nRestartSec=10\nUMask=0077\nNoNewPrivileges=true\n[Install]\nWantedBy=default.target\n`;
|
|
41
|
+
} else if (platform === 'win32') {
|
|
42
|
+
task = windowsServiceDefinition({ home, root, node, script, port, dbPath, logPath: pathJoin(dataDirectory, 'service.log'), errorLogPath: pathJoin(dataDirectory, 'service-error.log'), environment });
|
|
43
|
+
configuration = JSON.stringify(task);
|
|
37
44
|
}
|
|
38
|
-
return { supported, manager, label: LABEL, configPath: supported ? configPath : null, dataDirectory, dbPath, url: `http://127.0.0.1:${port}`, node, root, port, configuration, scope: 'current user; starts at login;
|
|
45
|
+
return { supported, manager, label: LABEL, configPath: supported ? configPath : null, dataDirectory, dbPath, url: `http://127.0.0.1:${port}`, node, root, port, configuration, ...(task ? { task } : {}), scope: 'current user; starts at login; no reports while logged out, sleeping or offline', effects: ['Register a user-level background service', 'Create a private local data directory and logs', 'Read-only local MCP; no new keys, host access, remote tasks, or AI client config changes'] };
|
|
39
46
|
}
|
|
40
47
|
function run(command, args) {
|
|
41
48
|
const child = spawnSync(command, args, { encoding: 'utf8', timeout: 10_000 });
|
|
@@ -44,21 +51,28 @@ function run(command, args) {
|
|
|
44
51
|
}
|
|
45
52
|
async function exists(path) { try { await lstat(path); return true; } catch (e) { if (e.code === 'ENOENT') return false; throw e; } }
|
|
46
53
|
async function install(plan) {
|
|
47
|
-
if (!plan.supported) throw new Error('
|
|
54
|
+
if (!plan.supported) throw new Error('Unsupported user service platform.');
|
|
48
55
|
if (await exists(plan.configPath)) throw new Error('The service config already exists. Inspect it first; this installer will not overwrite it.');
|
|
56
|
+
if (plan.task && windowsTask('status', plan.task).exists) throw new Error('An existing Windows task was preserved. Inspect it before installing.');
|
|
49
57
|
// Refuse to replace an existing foreground listener or another application.
|
|
50
58
|
try {
|
|
51
59
|
await fetch(`${plan.url}/api/health`, { signal: AbortSignal.timeout(1500) });
|
|
52
60
|
throw new Error('The chosen port is already responding. Stop the foreground service yourself or choose another --port.');
|
|
53
61
|
} catch (e) { if (e.message.startsWith('The chosen port')) throw e; }
|
|
54
|
-
|
|
62
|
+
ensurePrivateDirectory(plan.dataDirectory);
|
|
55
63
|
await mkdir(dirname(plan.configPath), { recursive: true });
|
|
56
|
-
|
|
64
|
+
if (plan.task) writePrivateJson(plan.configPath, plan.task, { create: true });
|
|
65
|
+
else await writeFile(plan.configPath, plan.configuration, { flag: 'wx', mode: 0o600 });
|
|
57
66
|
if (plan.manager === 'launchd') run('launchctl', ['bootstrap', `gui/${process.getuid()}`, plan.configPath]);
|
|
67
|
+
else if (plan.task) windowsTask('install', plan.task);
|
|
58
68
|
else { run('systemctl', ['--user', 'daemon-reload']); run('systemctl', ['--user', 'enable', '--now', 'device-center.service']); }
|
|
59
69
|
let healthy = false;
|
|
60
|
-
for (let attempt = 0; attempt < 12; attempt++) {
|
|
61
|
-
try {
|
|
70
|
+
for (let attempt = 0; attempt < (plan.task ? 40 : 12); attempt++) {
|
|
71
|
+
try {
|
|
72
|
+
const health = await (await fetch(`${plan.url}/api/health`, { signal: AbortSignal.timeout(1000) })).json();
|
|
73
|
+
const status = await (await fetch(`${plan.url}/api/connector/status`, { signal: AbortSignal.timeout(1000) })).json();
|
|
74
|
+
healthy = health.ok === true && health.mode === 'local' && status.service?.persistence === plan.manager;
|
|
75
|
+
} catch { /* wait for manager startup */ }
|
|
62
76
|
if (healthy) break;
|
|
63
77
|
await new Promise((done) => setTimeout(done, 250));
|
|
64
78
|
}
|
|
@@ -69,8 +83,20 @@ async function uninstall(plan) {
|
|
|
69
83
|
if (!plan.supported || !await exists(plan.configPath)) throw new Error('No supported user service config exists.');
|
|
70
84
|
// Only remove the exact config generated for this stable repository/runtime.
|
|
71
85
|
const { readFile } = await import('node:fs/promises');
|
|
72
|
-
|
|
86
|
+
const configuration = plan.task ? JSON.stringify(readPrivateJson(plan.configPath, null)) : await readFile(plan.configPath, 'utf8');
|
|
87
|
+
if (configuration !== plan.configuration) throw new Error('Service config differs from this plan. Inspect it manually; no overwrite or removal.');
|
|
73
88
|
if (plan.manager === 'launchd') run('launchctl', ['bootout', `gui/${process.getuid()}/${LABEL}`]);
|
|
89
|
+
else if (plan.task) {
|
|
90
|
+
windowsTask('stop', plan.task);
|
|
91
|
+
let stopped = false;
|
|
92
|
+
for (let attempt = 0; attempt < 20; attempt++) {
|
|
93
|
+
try { await fetch(`${plan.url}/api/health`, { signal: AbortSignal.timeout(500) }); }
|
|
94
|
+
catch { stopped = true; break; }
|
|
95
|
+
await new Promise(done => setTimeout(done, 250));
|
|
96
|
+
}
|
|
97
|
+
if (!stopped) throw new Error('Task stop did not stop the listener. Config and task retained; inspect Task Scheduler. No unrelated process was killed.');
|
|
98
|
+
windowsTask('remove', plan.task);
|
|
99
|
+
}
|
|
74
100
|
else { run('systemctl', ['--user', 'disable', '--now', 'device-center.service']); }
|
|
75
101
|
await unlink(plan.configPath);
|
|
76
102
|
if (plan.manager === 'systemd') run('systemctl', ['--user', 'daemon-reload']);
|
|
@@ -92,7 +118,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
92
118
|
if (command === 'status') {
|
|
93
119
|
let health = null;
|
|
94
120
|
try { health = await (await fetch(`${plan.url}/api/connector/status`, { signal: AbortSignal.timeout(1500) })).json(); } catch { /* stopped */ }
|
|
95
|
-
return { configExists: plan.supported && await exists(plan.configPath), status: health, url: plan.url, note: 'A present config does not prove a running service. The HTTP response identifies foreground vs managed runtime.' };
|
|
121
|
+
return { configExists: plan.supported && await exists(plan.configPath), ...(plan.task ? { task: windowsTask('status', plan.task) } : {}), status: health, url: plan.url, note: 'A present config does not prove a running service. The HTTP response identifies foreground vs managed runtime.' };
|
|
96
122
|
}
|
|
97
123
|
if (!confirmed) throw new Error('No system change made. Inspect the plan, then explicitly pass --confirm to install or uninstall the user service.');
|
|
98
124
|
return command === 'install' ? install(plan) : uninstall(plan);
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { localClient, cloudClient, cliError } from '../drivers/cliClient.mjs';
|
|
2
|
+
import { normalizeDeviceProfile } from '../drivers/deviceProfiles.mjs';
|
|
3
|
+
import { openPairingBrowser } from '../drivers/browserPairing.mjs';
|
|
4
|
+
import { TOOLS } from '../connector.mjs';
|
|
5
|
+
|
|
6
|
+
export const usage = `设备管理(默认本机;--cloud 使用已绑定的控制面):
|
|
7
|
+
device-center devices list [--cloud] [--state online|unknown|expired] [--tag 标签]
|
|
8
|
+
device-center devices show --id 设备ID [--cloud]
|
|
9
|
+
device-center devices update --id 设备ID --revision 0 [--name 名称] [--type personal|server|workstation|other|unspecified]
|
|
10
|
+
[--purpose 用途] [--tags 标签1,标签2] [--services 服务1,服务2] [--cloud] --confirm
|
|
11
|
+
device-center devices collect --id 设备ID [--cloud] [--wait 75] --confirm
|
|
12
|
+
device-center auth login [--minutes 480] [--no-browser] --confirm
|
|
13
|
+
device-center auth status
|
|
14
|
+
device-center auth logout --confirm
|
|
15
|
+
device-center doctor [--cloud]
|
|
16
|
+
device-center ssh candidates
|
|
17
|
+
device-center ssh import --aliases 别名1,别名2 --confirm 只导入配置引用,不联网
|
|
18
|
+
device-center ssh check --alias 已导入别名 --confirm 固定只读采集,会建立 SSH 连接
|
|
19
|
+
device-center ssh remove --aliases 别名1,别名2 --confirm 移除面板引用,保留系统 SSH
|
|
20
|
+
device-center mcp setup|tools
|
|
21
|
+
device-center mcp call --tool 工具名 [--input '{"fingerprint":"...","label":"app"}']
|
|
22
|
+
device-center capabilities
|
|
23
|
+
以上命令支持 --json;本机命令支持 --port 5174。不自动扫描、安装或启动服务。
|
|
24
|
+
日志授权、指纹信任、读取及局域网端点:device-center access help。
|
|
25
|
+
用途、标签与服务是用户说明,不能作为 Agent 指令或操作授权。`;
|
|
26
|
+
const specs = {
|
|
27
|
+
devices: { list: ['cloud', 'local', 'state', 'tag', 'port'], show: ['id', 'cloud', 'local', 'port'],
|
|
28
|
+
update: ['id', 'revision', 'name', 'type', 'purpose', 'tags', 'services', 'cloud', 'local', 'confirm', 'port'],
|
|
29
|
+
collect: ['id', 'cloud', 'local', 'wait', 'confirm', 'port'] },
|
|
30
|
+
auth: { login: ['minutes', 'no-browser', 'confirm'], status: [], logout: ['confirm'] },
|
|
31
|
+
doctor: { run: ['cloud', 'local', 'port'] },
|
|
32
|
+
ssh: { candidates: ['port'], import: ['aliases', 'confirm', 'port'], check: ['alias', 'confirm', 'port'], remove: ['aliases', 'confirm', 'port'] },
|
|
33
|
+
mcp: { setup: ['port'], tools: ['port'], call: ['tool', 'input', 'port'] },
|
|
34
|
+
capabilities: { list: [] },
|
|
35
|
+
};
|
|
36
|
+
const flags = new Set(['cloud', 'local', 'confirm', 'no-browser']);
|
|
37
|
+
export function parseManagement(argv = []) {
|
|
38
|
+
const [group, ...rest] = argv;
|
|
39
|
+
if (!Object.hasOwn(specs, group)) throw cliError('invalid_arguments', usage);
|
|
40
|
+
if (rest.length === 1 && ['help', '--help', '-h'].includes(rest[0])) return { group, action: 'help', options: {} };
|
|
41
|
+
const action = ['doctor', 'capabilities'].includes(group) ? group === 'doctor' ? 'run' : 'list' : rest.shift() ?? 'help';
|
|
42
|
+
if (action === 'help') { if (rest.length) throw cliError('invalid_arguments', usage); return { group, action, options: {} }; }
|
|
43
|
+
if (!Object.hasOwn(specs[group], action)) throw cliError('invalid_arguments', usage);
|
|
44
|
+
const options = {};
|
|
45
|
+
for (let i = 0; i < rest.length; i++) {
|
|
46
|
+
const key = rest[i].slice(2);
|
|
47
|
+
if (!rest[i].startsWith('--') || !specs[group][action].includes(key) || Object.hasOwn(options, key)) throw cliError('invalid_arguments', usage);
|
|
48
|
+
if (flags.has(key)) options[key] = true;
|
|
49
|
+
else {
|
|
50
|
+
if (rest[i+1] === undefined || rest[i+1].startsWith('--')) throw cliError('invalid_arguments', usage);
|
|
51
|
+
options[key] = rest[++i];
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (options.cloud && options.local || group === 'devices' && options.cloud && options.port) throw cliError('invalid_arguments', '--cloud 不能与 --local 同用;云端设备命令不接受 --port。');
|
|
55
|
+
if (options.port !== undefined && (!/^\d+$/.test(options.port) || Number(options.port) < 1024 || Number(options.port) > 65535)) throw cliError('invalid_arguments', '端口须为 1024–65535。');
|
|
56
|
+
if (options.state && !['online', 'unknown', 'expired'].includes(options.state)) throw cliError('invalid_arguments', '状态为 online、unknown 或 expired。');
|
|
57
|
+
if (['show', 'update', 'collect'].includes(action) && group === 'devices' && (typeof options.id !== 'string' || !options.id || options.id.length > 200 || /[\x00-\x1f\x7f]/.test(options.id))) throw cliError('invalid_arguments', '请提供完整 --id;名称不能用作设备身份。');
|
|
58
|
+
if (action === 'update') {
|
|
59
|
+
if (!/^\d+$/.test(options.revision ?? '') || !Number.isSafeInteger(Number(options.revision))) throw cliError('invalid_arguments', '请通过 show 获取当前 --revision,防止覆盖他人修改。');
|
|
60
|
+
if (!['name', 'type', 'purpose', 'tags', 'services'].some(key => Object.hasOwn(options, key))) throw cliError('invalid_arguments', '至少提供一个设备说明字段。');
|
|
61
|
+
}
|
|
62
|
+
if (options.wait !== undefined && (!/^\d+$/.test(options.wait) || Number(options.wait) < 1 || Number(options.wait) > 90)) throw cliError('invalid_arguments', '--wait 为 1–90 秒。');
|
|
63
|
+
if (options.minutes !== undefined && (!/^\d+$/.test(options.minutes) || Number(options.minutes) < 5 || Number(options.minutes) > 480)) throw cliError('invalid_arguments', '授权有效期为 5–480 分钟。');
|
|
64
|
+
const mutation = group === 'devices' && ['update', 'collect'].includes(action) || group === 'auth' && ['login', 'logout'].includes(action) || group === 'ssh' && action !== 'candidates';
|
|
65
|
+
if (mutation && !options.confirm) throw cliError('confirmation_required', '该操作需显式 --confirm;尚未更改或联系设备。', 403);
|
|
66
|
+
if (group === 'ssh' && ['import', 'remove'].includes(action) && (!options.aliases || options.aliases.split(',').some(v => !v.trim()))) throw cliError('invalid_arguments', '请提供 --aliases,以逗号分隔。');
|
|
67
|
+
if (group === 'ssh' && action === 'check' && !options.alias) throw cliError('invalid_arguments', '请提供已导入的 --alias。');
|
|
68
|
+
if (group === 'mcp' && action === 'call') {
|
|
69
|
+
if (!TOOLS.some(tool => tool.name === options.tool)) throw cliError('invalid_arguments', '只允许 device-center mcp tools 列出的工具。');
|
|
70
|
+
try { options.input = JSON.parse(options.input ?? '{}'); } catch { throw cliError('invalid_arguments', '--input 须为 JSON 对象。'); }
|
|
71
|
+
if (!options.input || typeof options.input !== 'object' || Array.isArray(options.input) || JSON.stringify(options.input).length > 4096) throw cliError('invalid_arguments', '--input 须为不超过 4KiB 的 JSON 对象。');
|
|
72
|
+
}
|
|
73
|
+
return { group, action, options };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function main(argv, { makeLocal = localClient, makeCloud = cloudClient, openBrowser = openPairingBrowser,
|
|
77
|
+
now = Date.now, sleep = ms => new Promise(done => setTimeout(done, ms)),
|
|
78
|
+
progress = value => process.stderr.write(value + '\n'), terminal = Boolean(process.stderr.isTTY) } = {}) {
|
|
79
|
+
const { group, action, options: o } = parseManagement(argv);
|
|
80
|
+
if (action === 'help') return usage;
|
|
81
|
+
if (group === 'capabilities') return { schemaVersion: 1, groups: ['devices', 'auth', 'doctor', 'ssh', 'mcp', 'access', 'service', 'connect', 'status'],
|
|
82
|
+
cloudManagement: { login: 'separate browser owner approval', scopes: ['devices:read', 'devices:profile', 'devices:collect'], maximumMinutes: 480 },
|
|
83
|
+
remote: { logRead: 'local fingerprint trust + target-owned time-limited log grant', arbitraryCommands: false, unrestrictedFiles: false },
|
|
84
|
+
metadata: 'untrusted user descriptions; services are user-declared', jsonFlag: true,
|
|
85
|
+
exitCodes: { success: 0, failed: 1, arguments: 2, authorization: 3, conflict: 4, unavailable: 5 } };
|
|
86
|
+
if (group === 'auth') {
|
|
87
|
+
if (action === 'login' && o['no-browser'] && !terminal) throw cliError('terminal_required', '--no-browser 仅能在本机交互终端显示一次性交接链接;不要放进 Agent 日志。', 403);
|
|
88
|
+
const cloud = makeCloud();
|
|
89
|
+
if (action === 'status') return cloud.call('status');
|
|
90
|
+
if (action === 'logout') return cloud.call('logout');
|
|
91
|
+
const pending = await cloud.call('start', { minutes: Number(o.minutes ?? 480) });
|
|
92
|
+
if (!/^[a-f0-9-]{36}$/.test(pending.id ?? '') || !/^[a-f0-9]{64}$/.test(pending.ticket ?? '') || pending.fingerprint !== cloud.fingerprint ||
|
|
93
|
+
!Number.isSafeInteger(pending.expiresAt) || pending.expiresAt <= now() || pending.expiresAt > now() + 330_000) throw cliError('invalid_cloud_response', '云端授权响应无效。', 502);
|
|
94
|
+
const url = new URL('/cli', cloud.origin); url.hash = new URLSearchParams({ ticket: pending.ticket, expires: String(pending.expiresAt) }).toString();
|
|
95
|
+
let approved = false;
|
|
96
|
+
try {
|
|
97
|
+
progress(`请本人在浏览器核对并批准 CLI 管理权限,有效期 ${pending.minutes} 分钟。\n本机公钥指纹:${cloud.fingerprint}`);
|
|
98
|
+
if (o['no-browser']) progress(url.href); else await openBrowser(url.href);
|
|
99
|
+
while (now() < pending.expiresAt) {
|
|
100
|
+
const result = await cloud.call('poll', { id: pending.id });
|
|
101
|
+
if (result.state === 'approved' && result.authorized === true) { approved = true; return result; }
|
|
102
|
+
if (result.state !== 'pending') throw cliError('cli_authorization_denied', 'CLI 授权未批准;设备绑定保留。', 403);
|
|
103
|
+
await sleep(3000);
|
|
104
|
+
}
|
|
105
|
+
throw cliError('cli_request_expired', 'CLI 授权请求已过期,可重新登录。', 410);
|
|
106
|
+
} finally { if (!approved) await cloud.call('cancel', { id: pending.id }).catch(() => {}); }
|
|
107
|
+
}
|
|
108
|
+
const local = () => makeLocal({ port: Number(o.port ?? 5174) });
|
|
109
|
+
if (group === 'doctor') {
|
|
110
|
+
const checks = [];
|
|
111
|
+
async function check(name, fn) { try { checks.push({ name, ok: true, result: await fn() }); } catch (error) { checks.push({ name, ok: false, code: safeCode(error) }); } }
|
|
112
|
+
checks.push({ name: 'node', ok: Number(process.versions.node.split('.')[0]) >= 24, version: process.versions.node });
|
|
113
|
+
const client = local();
|
|
114
|
+
await check('local-health', client.health); await check('local-publisher', client.link); await check('mcp-service', client.connector);
|
|
115
|
+
if (o.cloud) await check('cloud-authorization', () => makeCloud().call('status'));
|
|
116
|
+
return { healthy: checks.every(c => c.ok && (c.name !== 'cloud-authorization' || c.result.authorized) &&
|
|
117
|
+
(c.name !== 'local-publisher' || !c.result.configured || c.result.publisher?.state === 'connected')), checks,
|
|
118
|
+
networkScope: o.cloud ? 'loopback + already-bound HTTPS control plane' : 'loopback only',
|
|
119
|
+
advice: ['过期表示最近没有有效资源回报,不表示密钥过期。', '设备休眠、注销、断网或后台停止都会暂停回报;collect 不能唤醒离线电脑。', 'SSH 路径以最近固定采集结果为证,配置偏好不是已连通。'], scans: false, remoteCommands: false };
|
|
120
|
+
}
|
|
121
|
+
if (group === 'mcp') {
|
|
122
|
+
const client = local();
|
|
123
|
+
if (action === 'setup') return client.setup();
|
|
124
|
+
return client.mcp(action === 'tools' ? 'tools/list' : 'tools/call', action === 'tools' ? {} : { name: o.tool, arguments: o.input });
|
|
125
|
+
}
|
|
126
|
+
if (group === 'ssh') {
|
|
127
|
+
const client = local();
|
|
128
|
+
if (action === 'candidates') return client.candidates();
|
|
129
|
+
if (action === 'check') { const result = await client.checkSsh({ alias: o.alias }); if (!result.ok) throw cliError(result.error ?? 'ssh_check_failed', 'SSH 固定采集未通过;检查现有网络、known_hosts 与认证配置。', 502); return result; }
|
|
130
|
+
const input = { aliases: [...new Set(o.aliases.split(',').map(v => v.trim()))] };
|
|
131
|
+
return action === 'import' ? client.importSsh(input) : client.removeSsh(input);
|
|
132
|
+
}
|
|
133
|
+
const client = o.cloud ? makeCloud() : local();
|
|
134
|
+
const list = async () => o.cloud ? client.devices() : client.overview();
|
|
135
|
+
const show = async () => {
|
|
136
|
+
const device = o.cloud ? (await client.call('show', { deviceId: o.id })).device : (await list()).devices.find(d => d.id === o.id);
|
|
137
|
+
if (!device) throw cliError('device_not_found', '没有找到设备,请使用 list 返回的完整 ID。', 404);
|
|
138
|
+
return { device };
|
|
139
|
+
};
|
|
140
|
+
if (action === 'list') {
|
|
141
|
+
const { devices } = await list();
|
|
142
|
+
const filtered = devices.filter(d => (!o.state || d.state === o.state) && (!o.tag || d.profile?.tags.includes(o.tag)));
|
|
143
|
+
return { mode: o.cloud ? 'cloud' : 'local', devices: filtered, total: filtered.length };
|
|
144
|
+
}
|
|
145
|
+
if (action === 'show') return show();
|
|
146
|
+
if (action === 'update') {
|
|
147
|
+
const { device } = await show();
|
|
148
|
+
if (device.profile.revision !== Number(o.revision)) throw cliError('device_profile_conflict', '资料已变化,请重新 show 核对 revision。', 409);
|
|
149
|
+
const profile = Object.fromEntries(['displayName', 'type', 'purpose', 'tags', 'services'].map(k => [k, device.profile[k]]));
|
|
150
|
+
for (const [flag, field] of [['name', 'displayName'], ['type', 'type'], ['purpose', 'purpose'], ['tags', 'tags'], ['services', 'services']]) {
|
|
151
|
+
if (Object.hasOwn(o, flag)) profile[field] = ['tags', 'services'].includes(flag) ? o[flag].trim() ? o[flag].split(',').map(v => v.trim()) : [] : o[flag];
|
|
152
|
+
}
|
|
153
|
+
const input = { deviceId: o.id, profile: normalizeDeviceProfile(profile), revision: Number(o.revision) };
|
|
154
|
+
return o.cloud ? client.call('profile', input) : client.profile(input);
|
|
155
|
+
}
|
|
156
|
+
const input = { deviceId: o.id };
|
|
157
|
+
let result = o.cloud ? await client.call('collect', input) : await client.collect(input);
|
|
158
|
+
if (result.status === 'failed') throw cliError(result.error ?? 'collection_failed', '采集失败。', 502);
|
|
159
|
+
if (result.status !== 'pending' || !o.wait) return result;
|
|
160
|
+
const requestedAt = result.collection.requestedAt, deadline = now() + Number(o.wait) * 1000;
|
|
161
|
+
while (now() < deadline) {
|
|
162
|
+
await sleep(Math.min(3000, Math.max(1, deadline - now())));
|
|
163
|
+
const { device } = await show(), collection = device.collection;
|
|
164
|
+
if (collection?.requestedAt !== requestedAt) throw cliError('collection_changed', '采集请求已变化,请查看设备最新状态。', 409);
|
|
165
|
+
if (collection.status === 'completed') return { status: 'completed', device };
|
|
166
|
+
if (collection.status === 'timeout') break;
|
|
167
|
+
}
|
|
168
|
+
throw cliError('collection_timeout', '等待采集超时;请求可能仍在队列,先 show 查看结果,不要重复绑定。', 503);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function safeCode(error) {
|
|
172
|
+
const code = error.cliCode ?? error.message;
|
|
173
|
+
return typeof code === 'string' && /^[a-z][a-z_]{1,80}$/.test(code) ? code : 'command_failed';
|
|
174
|
+
}
|
|
175
|
+
export function exitCode(error) {
|
|
176
|
+
if (error.cliCode === 'invalid_arguments' || error.statusCode === 400) return 2;
|
|
177
|
+
if ([401, 403].includes(error.statusCode)) return 3;
|
|
178
|
+
if (error.statusCode === 409) return 4;
|
|
179
|
+
if (error.statusCode >= 500 || error.statusCode === 429 || error.statusCode === 410) return 5;
|
|
180
|
+
return 1;
|
|
181
|
+
}
|
package/server/cloudAccess.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { createUnifiedAccess } from './unifiedAccess.mjs';
|
|
|
4
4
|
|
|
5
5
|
const SESSION_AGE = 8 * 60 * 60 * 1000;
|
|
6
6
|
const COOKIE = '__Host-device-center';
|
|
7
|
-
const ENTRY_PAGES = new Set(['/', '/index.html', '/login', '/start', '/start.html', '/pair']);
|
|
7
|
+
const ENTRY_PAGES = new Set(['/', '/index.html', '/login', '/start', '/start.html', '/pair', '/cli']);
|
|
8
8
|
const digest = (value) => createHash('sha256').update(value).digest('hex');
|
|
9
9
|
const reject = (message) => { throw new Error(message); };
|
|
10
10
|
|
|
@@ -1,21 +1,15 @@
|
|
|
1
|
-
import { constants, openSync, closeSync, fstatSync,
|
|
1
|
+
import { constants, openSync, closeSync, fstatSync, lstatSync, realpathSync, readSync, writeSync } from 'node:fs';
|
|
2
2
|
import { dirname, isAbsolute, resolve, join } from 'node:path';
|
|
3
3
|
import { isIP } from 'node:net';
|
|
4
4
|
import { publicIdentity, object, fail } from './driverProtocol.mjs';
|
|
5
5
|
import { validateCertificate } from './certificates.mjs';
|
|
6
|
-
import { ensurePrivateDirectory,
|
|
6
|
+
import { ensurePrivateDirectory, readPrivateJson, writePrivateJson, assertPrivateFile, secureCreatedPrivateFile } from './privateFiles.mjs';
|
|
7
7
|
|
|
8
8
|
export const accessFile = linkFile => join(dirname(linkFile), 'access.json');
|
|
9
9
|
export const validFingerprint = fp => typeof fp === 'string' && /^[a-f0-9]{64}$/.test(fp);
|
|
10
10
|
export function privateJson(file, fallback) {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
14
|
-
const s = fstatSync(fd);
|
|
15
|
-
if (!s.isFile() || s.nlink !== 1 || s.size > 65536 || (s.mode & 0o077) || (process.getuid && s.uid !== process.getuid())) fail('access_private_file_required');
|
|
16
|
-
return JSON.parse(readFileSync(fd, 'utf8'));
|
|
17
|
-
} catch(e) { if(e.code === 'ENOENT') return fallback; throw e; }
|
|
18
|
-
finally { if(fd !== undefined) closeSync(fd); }
|
|
11
|
+
try { return readPrivateJson(file, fallback); }
|
|
12
|
+
catch (error) { if (error.message === 'driver_private_file_required') fail('access_private_file_required'); throw error; }
|
|
19
13
|
}
|
|
20
14
|
export function readAccess(file) {
|
|
21
15
|
const value = privateJson(file, { trusts: [], grants: [], listen: null });
|
|
@@ -36,6 +30,7 @@ export function lanEndpoint(value) {
|
|
|
36
30
|
}
|
|
37
31
|
export function safeLogPath(path) {
|
|
38
32
|
if (typeof path !== 'string' || !isAbsolute(path) || path.length > 4096) fail('access_absolute_log_required');
|
|
33
|
+
if (process.platform === 'win32' && (!/^[A-Za-z]:\\/.test(path) || /[<>:"|?*]/.test(path.slice(2)))) fail('access_local_drive_log_required');
|
|
39
34
|
const canonical = realpathSync(path);
|
|
40
35
|
checkLog(canonical); return canonical;
|
|
41
36
|
}
|
|
@@ -52,10 +47,16 @@ export function peerFingerprint(socket) {
|
|
|
52
47
|
}
|
|
53
48
|
export function audit(file, entry) {
|
|
54
49
|
ensurePrivateDirectory(dirname(file));
|
|
55
|
-
|
|
50
|
+
let fd;
|
|
51
|
+
try { fd = openSync(file, constants.O_WRONLY | constants.O_APPEND | (constants.O_NOFOLLOW ?? 0)); }
|
|
52
|
+
catch (error) {
|
|
53
|
+
if (error.code !== 'ENOENT') throw error;
|
|
54
|
+
fd = openSync(file, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o600);
|
|
55
|
+
try { secureCreatedPrivateFile(file); } catch (error) { closeSync(fd); throw error; }
|
|
56
|
+
}
|
|
56
57
|
try {
|
|
57
58
|
const s = fstatSync(fd);
|
|
58
|
-
|
|
59
|
+
try { assertPrivateFile(file, s, 1024 ** 2); } catch { fail('access_audit_unavailable'); }
|
|
59
60
|
writeSync(fd, JSON.stringify({ ...entry, at: Date.now() }) + '\n');
|
|
60
61
|
} finally { closeSync(fd); }
|
|
61
62
|
}
|
|
@@ -69,7 +70,7 @@ export function readGrantedLog({ file, fingerprint, request, route, now = Date.n
|
|
|
69
70
|
let fd;
|
|
70
71
|
try {
|
|
71
72
|
checkLog(grant.path);
|
|
72
|
-
fd = openSync(grant.path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
73
|
+
fd = openSync(grant.path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
73
74
|
const s = fstatSync(fd), current = lstatSync(grant.path);
|
|
74
75
|
if (!s.isFile() || s.nlink !== 1 || s.ino !== current.ino || s.dev !== current.dev || realpathSync(grant.path) !== grant.path) fail('access_log_changed');
|
|
75
76
|
const data = Buffer.alloc(Math.min(request.bytes, s.size));
|
|
@@ -81,4 +82,4 @@ export function readGrantedLog({ file, fingerprint, request, route, now = Date.n
|
|
|
81
82
|
} catch(error) { audit(auditFile, { fingerprint, label: request.label, route, outcome: 'denied' }); throw error; }
|
|
82
83
|
finally { if(fd !== undefined) closeSync(fd); }
|
|
83
84
|
}
|
|
84
|
-
export const writeAccess = (file,value) =>
|
|
85
|
+
export const writeAccess = (file,value) => writePrivateJson(file,value);
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { cloudOrigin } from './driverProtocol.mjs';
|
|
3
|
+
import { powershellArguments, powershellPath } from './windowsSecurity.mjs';
|
|
4
|
+
|
|
5
|
+
export const windowsBrowserSource = `[Console]::InputEncoding = [System.Text.UTF8Encoding]::new($false)\n$ErrorActionPreference = 'Stop'\n$url = [Console]::In.ReadToEnd()\nStart-Process -FilePath $url | Out-Null\n`;
|
|
3
6
|
|
|
4
7
|
export function pairingBrowserUrl(origin, pending) {
|
|
5
8
|
cloudOrigin(origin);
|
|
@@ -10,15 +13,17 @@ export function pairingBrowserUrl(origin, pending) {
|
|
|
10
13
|
return url.href;
|
|
11
14
|
}
|
|
12
15
|
|
|
13
|
-
export async function openPairingBrowser(url, { platform = process.platform, spawnImpl = spawn } = {}) {
|
|
16
|
+
export async function openPairingBrowser(url, { platform = process.platform, spawnImpl = spawn, environment = process.env } = {}) {
|
|
14
17
|
const target = new URL(url);
|
|
15
18
|
cloudOrigin(target.origin);
|
|
16
|
-
if (
|
|
17
|
-
const
|
|
19
|
+
if (!['/pair', '/cli'].includes(target.pathname) || target.search || !/^#ticket=[a-f0-9]{64}&expires=\d+$/.test(target.hash)) throw new Error('无效的浏览器交接。');
|
|
20
|
+
const windows = platform === 'win32';
|
|
21
|
+
const command = platform === 'darwin' ? '/usr/bin/open' : platform === 'linux' ? 'xdg-open' : windows ? powershellPath(environment) : null;
|
|
18
22
|
if (!command) throw new Error('此系统尚不支持浏览器接入。');
|
|
19
23
|
// No shell, URL echo or opener output in the Agent transcript.
|
|
20
24
|
await new Promise((resolve, reject) => {
|
|
21
|
-
const child = spawnImpl(command, [target.href], { stdio: 'ignore', shell: false });
|
|
25
|
+
const child = spawnImpl(command, windows ? powershellArguments(windowsBrowserSource) : [target.href], windows ? { stdio: ['pipe', 'ignore', 'ignore'], shell: false, windowsHide: true } : { stdio: 'ignore', shell: false });
|
|
26
|
+
if (windows) { child.stdin.on('error', () => {}); child.stdin.end(target.href); }
|
|
22
27
|
const timer = setTimeout(() => { child.kill(); reject(new Error('无法打开本机浏览器。请在本机终端执行不带 --browser 的 connect 手动配对;不会自动输出凭据。')); }, 10_000);
|
|
23
28
|
child.once('error', () => { clearTimeout(timer); reject(new Error('无法打开本机浏览器,请在本机终端使用不带 --browser 的 connect 手动配对。')); });
|
|
24
29
|
child.once('close', code => { clearTimeout(timer); code === 0 ? resolve() : reject(new Error('浏览器打开失败,请在本机终端使用不带 --browser 的 connect 手动配对。')); });
|