device-center 0.2.0-beta.3 → 0.2.0-beta.5
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 +2 -0
- package/Dockerfile +2 -1
- package/README.md +24 -7
- package/SECURITY.md +8 -2
- package/bin/device-center.mjs +6 -5
- package/docs/agent-install.md +43 -9
- package/docs/driver-binding.md +14 -4
- package/docs/friend-trial.md +5 -5
- package/docs/hosted-access.md +1 -1
- package/docs/npm.md +13 -9
- package/docs/onboarding.md +5 -3
- package/docs/remote-access.md +2 -2
- package/docs/roadmap.md +13 -1
- package/docs/security-and-sync.md +4 -0
- package/docs/troubleshooting.md +11 -0
- package/docs/windows-acceptance.md +57 -0
- package/favicon.svg +4 -0
- package/index.html +1 -0
- package/login.html +2 -2
- package/npm-shrinkwrap.json +2 -2
- package/package.json +4 -2
- package/scripts/access.mjs +0 -1
- package/scripts/driver.mjs +27 -11
- package/scripts/service.mjs +42 -16
- package/server/cloudAccess.mjs +7 -1
- package/server/drivers/access.mjs +15 -14
- package/server/drivers/browserPairing.mjs +46 -0
- package/server/drivers/cloudLink.mjs +19 -20
- package/server/drivers/cloudRegistry.mjs +23 -6
- 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 +30 -7
- package/server/unifiedAccess.mjs +24 -4
- package/src/api.js +4 -0
- package/src/install-prompt.js +2 -2
- package/src/install-walkthrough.js +49 -0
- package/src/login-flow.js +37 -0
- package/src/login.css +3 -1
- package/src/login.js +20 -11
- package/src/main.js +20 -12
- package/src/messages.js +1745 -162
- package/src/onboarding.js +60 -17
- package/src/pair.css +23 -0
- package/src/pair.js +60 -0
- package/src/pairing-handoff.js +30 -0
- package/src/start.css +265 -45
- package/src/start.js +155 -26
- package/start.html +143 -27
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.5",
|
|
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.5",
|
|
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.5",
|
|
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",
|
|
@@ -37,8 +37,10 @@
|
|
|
37
37
|
"docs/remote-access.md",
|
|
38
38
|
"npm-shrinkwrap.json",
|
|
39
39
|
"start.html",
|
|
40
|
+
"favicon.svg",
|
|
40
41
|
"docs/friend-trial.md",
|
|
41
|
-
"docs/i18n.md"
|
|
42
|
+
"docs/i18n.md",
|
|
43
|
+
"docs/windows-acceptance.md"
|
|
42
44
|
],
|
|
43
45
|
"publishConfig": {
|
|
44
46
|
"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
|
@@ -8,21 +8,23 @@ import { servicePlan } from './service.mjs';
|
|
|
8
8
|
import { cloudOrigin, publicIdentity, signedRequest } from '../server/drivers/driverProtocol.mjs';
|
|
9
9
|
import { cloudPost, defaultLinkFile, readLink, writeLink, linkSummary, ensurePrivateDirectory } from '../server/drivers/cloudLink.mjs';
|
|
10
10
|
import { issueCertificate } from '../server/drivers/certificates.mjs';
|
|
11
|
+
import { pairingBrowserUrl, openPairingBrowser, waitForFirstReport } from '../server/drivers/browserPairing.mjs';
|
|
11
12
|
|
|
12
|
-
const usage = 'npm run driver -- plan|connect|run|status|disconnect --cloud https://你的域名 [--share-ssh] [--port 5174] [--confirm]';
|
|
13
|
+
const usage = 'npm run driver -- plan|connect|run|status|disconnect --cloud https://你的域名 [--browser] [--share-ssh] [--port 5174] [--confirm]';
|
|
13
14
|
export function driverPlan(argv = []) {
|
|
14
15
|
const command = argv[0] ?? 'plan';
|
|
15
|
-
let origin = 'https://devices.owenshen.top', port = 5174, confirm = false, shareSsh = false;
|
|
16
|
+
let origin = 'https://devices.owenshen.top', port = 5174, confirm = false, shareSsh = false, browser = false;
|
|
16
17
|
for (let i = 1; i < argv.length; i++) {
|
|
17
18
|
if (argv[i] === '--cloud' && argv[i+1]) origin = argv[++i];
|
|
18
19
|
else if (argv[i] === '--port' && argv[i+1]) port = Number(argv[++i]);
|
|
19
20
|
else if (argv[i] === '--confirm') confirm = true;
|
|
20
21
|
else if (argv[i] === '--share-ssh') shareSsh = true;
|
|
22
|
+
else if (argv[i] === '--browser') browser = true;
|
|
21
23
|
else throw new Error(usage);
|
|
22
24
|
}
|
|
23
25
|
if (!['plan', 'connect', 'run', 'status', 'disconnect'].includes(command) || !Number.isInteger(port) || port < 1024 || port > 65535) throw new Error(usage);
|
|
24
26
|
cloudOrigin(origin);
|
|
25
|
-
return { command, origin, port, confirm, shareSsh, 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 };
|
|
26
28
|
}
|
|
27
29
|
export async function runLocal(plan) {
|
|
28
30
|
// Reuse only a running native Driver that implements this protocol; never stop another listener.
|
|
@@ -43,11 +45,10 @@ export async function runLocal(plan) {
|
|
|
43
45
|
const shutdown = () => instance.server.close(() => { instance.db.close(); process.exit(0); });
|
|
44
46
|
process.once('SIGINT', shutdown); process.once('SIGTERM', shutdown);
|
|
45
47
|
}
|
|
46
|
-
export async function main(argv = process.argv.slice(2)) {
|
|
47
|
-
const plan = driverPlan(argv)
|
|
48
|
+
export async function main(argv = process.argv.slice(2), { file = defaultLinkFile(), post = cloudPost, openBrowser = openPairingBrowser, runDriver = runLocal, waitReport = waitForFirstReport } = {}) {
|
|
49
|
+
const plan = driverPlan(argv);
|
|
48
50
|
if (Number(process.versions.node.split('.')[0]) < 24) throw new Error('需要 Node.js 24 或更高版本。');
|
|
49
51
|
if (plan.command === 'plan') return plan; // No files, key generation, network or installation.
|
|
50
|
-
if (process.platform === 'win32') throw new Error('Windows 身份文件的安全存储尚未实现,当前管理 Driver 只支持 macOS/Linux。');
|
|
51
52
|
let state = readLink(file);
|
|
52
53
|
if (plan.command === 'status') return linkSummary(state);
|
|
53
54
|
if (plan.command === 'run') { if (!state?.driverId || state.disabled) throw new Error('请先 connect 完成配对。'); await runLocal(plan); return linkSummary(state); }
|
|
@@ -66,16 +67,31 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
66
67
|
writeLink(file, state, { create: true });
|
|
67
68
|
}
|
|
68
69
|
if (!state.certificate) { state.certificate = await issueCertificate(state); writeLink(file, state); }
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
70
|
+
let pending;
|
|
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 } : {}) }); }
|
|
72
|
+
catch (error) {
|
|
73
|
+
if (plan.browser && error.statusCode === 400 && error.message === 'invalid_driver_input') throw new Error('控制面尚不支持浏览器接入,请升级控制面;或在本机终端使用不带 --browser 的 connect。未自动输出配对凭据。');
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
if (!/^[a-f0-9-]{36}$/.test(pending.id) || (!plan.browser && !/^[A-F0-9]{4}-[A-F0-9]{4}$/.test(pending.code)) || pending.fingerprint !== state.fingerprint || !Number.isSafeInteger(pending.expiresAt)) throw new Error('云端配对响应无效。');
|
|
77
|
+
if (plan.browser) {
|
|
78
|
+
const url = pairingBrowserUrl(plan.origin, pending);
|
|
79
|
+
console.log(`正在打开本机浏览器。请本人登录账号,核对设备后批准一次。\n本机公钥指纹:${state.fingerprint}\n无需填写配对码,也无需回复 Agent;程序会自动等待并核验。`);
|
|
80
|
+
await openBrowser(url);
|
|
81
|
+
} else console.log(`请打开 ${plan.origin},点“连接我的电脑”,在面板单独输入以下配对码(5分钟内有效):\n${pending.code}\n核对本机公钥指纹:${state.fingerprint}\n仅批准你刚在这台电脑发起的请求;配对码不放入智能体prompt或安装脚本。`);
|
|
72
82
|
const deadline = Math.min(pending.expiresAt, Date.now() + 300_000);
|
|
73
83
|
while (Date.now() < deadline) {
|
|
74
|
-
const result = await
|
|
84
|
+
const result = await post(plan.origin, '/api/driver/pair/poll', signedRequest(state.privateKey, plan.origin, '/api/driver/pair/poll', pending.id, {}));
|
|
75
85
|
if (result.state === 'approved') {
|
|
76
86
|
if (!/^[a-f0-9-]{36}$/.test(result.driverId) || typeof result.shareSsh !== 'boolean' || result.shareSsh && !plan.shareSsh) throw new Error('云端批准范围无效。');
|
|
77
87
|
state = { ...state, driverId: result.driverId, shareSsh: result.shareSsh, disabled: false }; writeLink(file, state);
|
|
78
|
-
console.log('已绑定。私钥留在本机,只回报获准字段;远程任务未授权。');
|
|
88
|
+
console.log('已绑定。私钥留在本机,只回报获准字段;远程任务未授权。');
|
|
89
|
+
const since = Date.now();
|
|
90
|
+
await runDriver(plan);
|
|
91
|
+
if (!plan.browser) return linkSummary(state);
|
|
92
|
+
const report = await waitReport({ port: plan.port, driverId: state.driverId, origin: state.origin, since });
|
|
93
|
+
console.log(report.verified ? '云端已接收首次资源回报,接入完成。' : '账号绑定已完成,但尚未确认首次资源回报。请检查本机服务与网络,不要重复绑定。');
|
|
94
|
+
return { ...linkSummary(state), firstReport: report };
|
|
79
95
|
}
|
|
80
96
|
if (result.state !== 'pending') throw new Error('配对响应无效。');
|
|
81
97
|
await new Promise(done => setTimeout(done, 5000));
|
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);
|
package/server/cloudAccess.mjs
CHANGED
|
@@ -4,6 +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
8
|
const digest = (value) => createHash('sha256').update(value).digest('hex');
|
|
8
9
|
const reject = (message) => { throw new Error(message); };
|
|
9
10
|
|
|
@@ -42,9 +43,14 @@ export function createCloudAccess({ publicOrigin, authFile, now = Date.now, fetc
|
|
|
42
43
|
origin: publicOrigin,
|
|
43
44
|
validate(request) {
|
|
44
45
|
// The process stays on loopback. The proxy preserves Host and overwrites X-Forwarded-Proto.
|
|
46
|
+
const site = request.headers['sec-fetch-site'];
|
|
47
|
+
// External links may open HTML pages; API requests, frames and writes remain isolated.
|
|
48
|
+
const entryNavigation = request.method === 'GET' && ['same-site', 'cross-site'].includes(site) &&
|
|
49
|
+
request.headers['sec-fetch-mode'] === 'navigate' && request.headers['sec-fetch-dest'] === 'document' &&
|
|
50
|
+
ENTRY_PAGES.has(new URL(request.url ?? '/', publicOrigin).pathname);
|
|
45
51
|
if (request.headers.host !== url.host || request.headers['x-forwarded-proto'] !== 'https' ||
|
|
46
52
|
(request.headers.origin && request.headers.origin !== publicOrigin) ||
|
|
47
|
-
(
|
|
53
|
+
(site && !['same-origin', 'none'].includes(site) && !entryNavigation)) {
|
|
48
54
|
throw Object.assign(new Error('cloud_origin_required'), { statusCode: 403 });
|
|
49
55
|
}
|
|
50
56
|
return publicOrigin;
|
|
@@ -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);
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
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`;
|
|
6
|
+
|
|
7
|
+
export function pairingBrowserUrl(origin, pending) {
|
|
8
|
+
cloudOrigin(origin);
|
|
9
|
+
if (!/^[a-f0-9]{64}$/.test(pending.browserTicket ?? '') || !Number.isSafeInteger(pending.expiresAt)) throw new Error('控制面尚不支持浏览器接入,请升级控制面;或在本机终端使用不带 --browser 的 connect。');
|
|
10
|
+
const url = new URL('/pair', origin);
|
|
11
|
+
// Fragments are excluded from HTTP requests and Referer; the page removes this immediately.
|
|
12
|
+
url.hash = new URLSearchParams({ ticket: pending.browserTicket, expires: String(pending.expiresAt) }).toString();
|
|
13
|
+
return url.href;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function openPairingBrowser(url, { platform = process.platform, spawnImpl = spawn, environment = process.env } = {}) {
|
|
17
|
+
const target = new URL(url);
|
|
18
|
+
cloudOrigin(target.origin);
|
|
19
|
+
if (target.pathname !== '/pair' || 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;
|
|
22
|
+
if (!command) throw new Error('此系统尚不支持浏览器接入。');
|
|
23
|
+
// No shell, URL echo or opener output in the Agent transcript.
|
|
24
|
+
await new Promise((resolve, reject) => {
|
|
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); }
|
|
27
|
+
const timer = setTimeout(() => { child.kill(); reject(new Error('无法打开本机浏览器。请在本机终端执行不带 --browser 的 connect 手动配对;不会自动输出凭据。')); }, 10_000);
|
|
28
|
+
child.once('error', () => { clearTimeout(timer); reject(new Error('无法打开本机浏览器,请在本机终端使用不带 --browser 的 connect 手动配对。')); });
|
|
29
|
+
child.once('close', code => { clearTimeout(timer); code === 0 ? resolve() : reject(new Error('浏览器打开失败,请在本机终端使用不带 --browser 的 connect 手动配对。')); });
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function waitForFirstReport({ port, driverId, origin, since, fetchImpl = fetch, now = Date.now, sleep = ms => new Promise(done => setTimeout(done, ms)), timeoutMs = 75_000 }) {
|
|
34
|
+
const deadline = now() + timeoutMs;
|
|
35
|
+
while (now() < deadline) {
|
|
36
|
+
try {
|
|
37
|
+
const reply = await fetchImpl(`http://127.0.0.1:${port}/api/driver/link`, { signal: AbortSignal.timeout(1500), redirect: 'error' });
|
|
38
|
+
if (reply.ok) {
|
|
39
|
+
const link = await reply.json();
|
|
40
|
+
if (link.protocol === 'device-center-driver-v1' && link.driverId === driverId && link.origin === origin && !link.disabled && link.publisher?.state === 'connected' && link.publisher.lastSuccess >= since) return { verified: true, receivedAt: link.publisher.lastSuccess };
|
|
41
|
+
}
|
|
42
|
+
} catch { /* Registration and cloud delivery are separate results. */ }
|
|
43
|
+
await sleep(1500);
|
|
44
|
+
}
|
|
45
|
+
return { verified: false, reason: 'first_report_timeout' };
|
|
46
|
+
}
|
|
@@ -1,38 +1,37 @@
|
|
|
1
|
-
import { constants, openSync, closeSync, fstatSync, readFileSync, mkdirSync, lstatSync, writeFileSync, renameSync } from 'node:fs';
|
|
2
1
|
import { homedir } from 'node:os';
|
|
3
|
-
import {
|
|
4
|
-
import { createHash, createPrivateKey, createPublicKey
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { createHash, createPrivateKey, createPublicKey } from 'node:crypto';
|
|
5
4
|
import { cloudOrigin, signedRequest, publicIdentity } from './driverProtocol.mjs';
|
|
6
5
|
import { observeLocalDriver } from './local.mjs';
|
|
7
6
|
import { validateCertificate } from './certificates.mjs';
|
|
7
|
+
import { readPrivateJson, writePrivateJson, defaultDataDirectory } from './privateFiles.mjs';
|
|
8
|
+
import { protectWindowsIdentity, unprotectWindowsIdentity } from './windowsSecurity.mjs';
|
|
9
|
+
export { ensurePrivateDirectory, defaultDataDirectory } from './privateFiles.mjs';
|
|
8
10
|
|
|
9
|
-
export const defaultLinkFile = (home = homedir(), platform = process.platform) => join(
|
|
11
|
+
export const defaultLinkFile = (home = homedir(), platform = process.platform) => join(defaultDataDirectory(home, platform), 'driver-link.json');
|
|
12
|
+
const decrypted = new Map();
|
|
10
13
|
export function readLink(file) {
|
|
11
|
-
let fd;
|
|
12
14
|
try {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
const stored = readPrivateJson(file, null, process.platform === 'win32' ? 32768 : 8192);
|
|
16
|
+
if (stored === null) return null;
|
|
17
|
+
let state = stored;
|
|
18
|
+
if (process.platform === 'win32') {
|
|
19
|
+
const cached = decrypted.get(file);
|
|
20
|
+
state = cached?.data === stored.data ? structuredClone(cached.state) : unprotectWindowsIdentity(stored);
|
|
21
|
+
if (stored.format !== 'device-center-dpapi-v1') throw new Error('windows_encrypted_identity_required');
|
|
22
|
+
if (decrypted.size > 8) decrypted.clear();
|
|
23
|
+
decrypted.set(file, { data: stored.data, state: structuredClone(state) });
|
|
24
|
+
}
|
|
17
25
|
cloudOrigin(state.origin);
|
|
18
26
|
const key = createPrivateKey(state.privateKey), identity = publicIdentity(state.publicKey);
|
|
19
27
|
if (key.asymmetricKeyType !== 'ed25519' || createPublicKey(key).export({ type: 'spki', format: 'pem' }).toString() !== identity.publicKey || identity.fingerprint !== state.fingerprint || typeof state.shareSsh !== 'boolean' || typeof state.disabled !== 'boolean' || (state.driverId != null && !/^[a-f0-9-]{36}$/.test(state.driverId))) throw new Error('driver_private_file_invalid');
|
|
20
28
|
if (state.certificate) validateCertificate(state.certificate, state.fingerprint, Date.now(), true);
|
|
21
29
|
return state;
|
|
22
30
|
} catch (error) { if (error.code === 'ENOENT') return null; throw new Error('driver_private_file_invalid'); }
|
|
23
|
-
finally { if (fd !== undefined) closeSync(fd); }
|
|
24
|
-
}
|
|
25
|
-
export function ensurePrivateDirectory(directory) {
|
|
26
|
-
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
27
|
-
const info = lstatSync(directory);
|
|
28
|
-
if (!info.isDirectory() || info.isSymbolicLink() || (info.mode & 0o077) || (process.getuid && info.uid !== process.getuid())) throw new Error('driver_private_directory_required');
|
|
29
31
|
}
|
|
30
32
|
export function writeLink(file, state, { create = false } = {}) {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const temp = `${file}.${randomBytes(6).toString('hex')}.tmp`;
|
|
34
|
-
writeFileSync(temp, JSON.stringify(state), { mode: 0o600, flag: 'wx' });
|
|
35
|
-
renameSync(temp, file);
|
|
33
|
+
writePrivateJson(file, process.platform === 'win32' ? protectWindowsIdentity(state) : state, { create });
|
|
34
|
+
decrypted.delete(file);
|
|
36
35
|
}
|
|
37
36
|
export function linkSummary(state) {
|
|
38
37
|
return { protocol: 'device-center-driver-v1', configured: Boolean(state?.driverId && !state.disabled), origin: state?.origin ?? null, driverId: state?.driverId ?? null, fingerprint: state?.fingerprint ?? null, secureTransportReady: Boolean(state?.certificate), shareSsh: Boolean(state?.shareSsh), disabled: Boolean(state?.disabled) };
|
|
@@ -20,6 +20,8 @@ export function createCloudRegistry({ db, origin, now = Date.now, multiUser = fa
|
|
|
20
20
|
}
|
|
21
21
|
db.exec('CREATE INDEX IF NOT EXISTS cloud_drivers_tenant_created ON cloud_drivers(tenant_id, created_at)');
|
|
22
22
|
for (const table of ['driver_pairings', 'cloud_drivers']) if (!db.prepare(`PRAGMA table_info(${table})`).all().some(c => c.name === 'certificate')) db.exec(`ALTER TABLE ${table} ADD COLUMN certificate TEXT`);
|
|
23
|
+
if (!db.prepare('PRAGMA table_info(driver_pairings)').all().some(c => c.name === 'browser_hash')) db.exec('ALTER TABLE driver_pairings ADD COLUMN browser_hash TEXT');
|
|
24
|
+
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS driver_pairings_browser ON driver_pairings(browser_hash)');
|
|
23
25
|
if (!db.prepare('PRAGMA table_info(cloud_drivers)').all().some(c => c.name === 'control_seen_at')) db.exec('ALTER TABLE cloud_drivers ADD COLUMN control_seen_at INTEGER');
|
|
24
26
|
db.exec(`CREATE TABLE IF NOT EXISTS driver_collection_requests (driver_id TEXT PRIMARY KEY, request_id TEXT NOT NULL,
|
|
25
27
|
requested_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, completed_at INTEGER)`);
|
|
@@ -43,6 +45,13 @@ export function createCloudRegistry({ db, origin, now = Date.now, multiUser = fa
|
|
|
43
45
|
if (!row || row.expires_at <= now()) fail('driver_pairing_expired', 410);
|
|
44
46
|
return row;
|
|
45
47
|
}
|
|
48
|
+
function credential(input) {
|
|
49
|
+
if (input.browserTicket !== undefined) {
|
|
50
|
+
if (input.code !== undefined || typeof input.browserTicket !== 'string' || !/^[a-f0-9]{64}$/.test(input.browserTicket)) fail('invalid_pairing_code');
|
|
51
|
+
return { column: 'browser_hash', hash: publicCodeHash(input.browserTicket) };
|
|
52
|
+
}
|
|
53
|
+
return { column: 'code_hash', hash: hashCode(input.code) };
|
|
54
|
+
}
|
|
46
55
|
function proof(row, path, input) {
|
|
47
56
|
verifyRequest(row.public_key, origin, path, input, now());
|
|
48
57
|
db.prepare('DELETE FROM driver_nonces WHERE expires_at<=?').run(now());
|
|
@@ -76,7 +85,8 @@ export function createCloudRegistry({ db, origin, now = Date.now, multiUser = fa
|
|
|
76
85
|
const registry = {
|
|
77
86
|
start(input) {
|
|
78
87
|
starts = limited(starts, 8);
|
|
79
|
-
object(input, ['publicKey', 'name', 'platform', 'shareSsh', 'certificate']);
|
|
88
|
+
object(input, ['publicKey', 'name', 'platform', 'shareSsh', 'certificate', 'browser']);
|
|
89
|
+
if (input.browser !== undefined && typeof input.browser !== 'boolean') fail('invalid_driver_scope');
|
|
80
90
|
const identity = publicIdentity(input.publicKey);
|
|
81
91
|
const certificate = input.certificate == null ? null : validateCertificate(input.certificate, identity.fingerprint, now());
|
|
82
92
|
const name = text(input.name), platform = text(input.platform, 64);
|
|
@@ -87,6 +97,11 @@ export function createCloudRegistry({ db, origin, now = Date.now, multiUser = fa
|
|
|
87
97
|
if (db.prepare('SELECT count(*) AS n FROM driver_pairings').get().n >= 32) fail('driver_pairing_busy', 429);
|
|
88
98
|
const id = randomUUID(), raw = randomBytes(4).toString('hex').toUpperCase(), code = `${raw.slice(0,4)}-${raw.slice(4)}`;
|
|
89
99
|
db.prepare('INSERT INTO driver_pairings (id,code_hash,public_key,fingerprint,name,platform,requested_ssh,created_at,expires_at,certificate) VALUES (?,?,?,?,?,?,?,?,?,?)').run(id, hashCode(code), identity.publicKey, identity.fingerprint, name, platform, Number(input.shareSsh), now(), now() + PAIR_AGE, certificate);
|
|
100
|
+
if (input.browser) {
|
|
101
|
+
const browserTicket = randomBytes(32).toString('hex');
|
|
102
|
+
db.prepare('UPDATE driver_pairings SET browser_hash=? WHERE id=?').run(publicCodeHash(browserTicket), id);
|
|
103
|
+
return { id, browserTicket, fingerprint: identity.fingerprint, expiresAt: now() + PAIR_AGE, pollIntervalMs: 5000 };
|
|
104
|
+
}
|
|
90
105
|
return { id, code, fingerprint: identity.fingerprint, expiresAt: now() + PAIR_AGE, verificationUrl: origin, pollIntervalMs: 5000 };
|
|
91
106
|
},
|
|
92
107
|
poll(input) {
|
|
@@ -102,24 +117,26 @@ export function createCloudRegistry({ db, origin, now = Date.now, multiUser = fa
|
|
|
102
117
|
},
|
|
103
118
|
lookup(input, tenantId) {
|
|
104
119
|
globalLookups = limited(globalLookups, 64);
|
|
105
|
-
limitAccount(lookups, tenantId); object(input, ['code']);
|
|
106
|
-
const
|
|
120
|
+
limitAccount(lookups, tenantId); object(input, ['code', 'browserTicket']);
|
|
121
|
+
const proof = credential(input);
|
|
122
|
+
const row = db.prepare(`SELECT * FROM driver_pairings WHERE ${proof.column}=?`).get(proof.hash);
|
|
107
123
|
if (!row || row.expires_at <= now() || row.driver_id) fail('driver_pairing_unavailable', 404);
|
|
108
124
|
return { id: row.id, name: row.name, platform: row.platform, fingerprint: row.fingerprint, requestedSsh: Boolean(row.requested_ssh), expiresAt: row.expires_at };
|
|
109
125
|
},
|
|
110
126
|
approve(input, tenantId) {
|
|
111
127
|
globalApprovals = limited(globalApprovals, 64); limitAccount(approvals, tenantId);
|
|
112
|
-
object(input, ['id', 'code', 'fingerprint', 'shareSsh']);
|
|
128
|
+
object(input, ['id', 'code', 'browserTicket', 'fingerprint', 'shareSsh']);
|
|
113
129
|
if (typeof input.id !== 'string' || typeof input.shareSsh !== 'boolean') fail('invalid_driver_approval');
|
|
114
|
-
if (multiUser && (typeof input.code !== 'string' || typeof input.fingerprint !== 'string')) fail('driver_pairing_confirmation_required');
|
|
130
|
+
if ((multiUser || input.browserTicket !== undefined) && ((typeof input.code !== 'string' && typeof input.browserTicket !== 'string') || typeof input.fingerprint !== 'string')) fail('driver_pairing_confirmation_required');
|
|
115
131
|
db.exec('BEGIN IMMEDIATE');
|
|
116
132
|
try {
|
|
117
133
|
const row = pairing(input.id);
|
|
134
|
+
if (row.browser_hash != null && ((input.code === undefined && input.browserTicket === undefined) || typeof input.fingerprint !== 'string')) fail('driver_pairing_confirmation_required');
|
|
118
135
|
if (row.driver_id) {
|
|
119
136
|
if (!db.prepare('SELECT id FROM cloud_drivers WHERE id=? AND tenant_id=?').get(row.driver_id, tenantId)) fail('driver_pairing_unavailable', 404);
|
|
120
137
|
fail('driver_pairing_consumed', 409);
|
|
121
138
|
}
|
|
122
|
-
if (input.code !== undefined
|
|
139
|
+
if (input.code !== undefined || input.browserTicket !== undefined) { const proof = credential(input); if (proof.hash !== row[proof.column]) fail('driver_pairing_unavailable', 404); }
|
|
123
140
|
if (input.fingerprint !== undefined && input.fingerprint !== row.fingerprint) fail('driver_pairing_confirmation_required');
|
|
124
141
|
if (input.shareSsh && !row.requested_ssh) fail('driver_scope_violation');
|
|
125
142
|
if (db.prepare('SELECT count(*) AS n FROM cloud_drivers WHERE tenant_id=? AND revoked_at IS NULL').get(tenantId).n >= 16) fail('driver_limit', 409);
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { constants, openSync, closeSync, fstatSync, readFileSync, mkdirSync, lstatSync, writeFileSync, renameSync } from 'node:fs';
|
|
2
|
+
import { dirname, join, win32 } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { randomBytes } from 'node:crypto';
|
|
5
|
+
import { windowsStorage } from './windowsSecurity.mjs';
|
|
6
|
+
|
|
7
|
+
export const defaultDataDirectory = (home = homedir(), platform = process.platform) => platform === 'darwin' ? join(home, 'Library', 'Application Support', 'Device Center') : platform === 'win32' ? win32.join(home, 'AppData', 'Local', 'Device Center') : join(home, '.local', 'share', 'device-center');
|
|
8
|
+
|
|
9
|
+
// Recheck Windows ACLs at least every 30s, or immediately when file metadata changes.
|
|
10
|
+
// This avoids launching PowerShell on every 5s control poll. No private data is cached here.
|
|
11
|
+
const checked = new Map();
|
|
12
|
+
export function secureCreatedPrivateFile(file) {
|
|
13
|
+
if (process.platform === 'win32') windowsStorage('secure-new-file', { path: file });
|
|
14
|
+
}
|
|
15
|
+
export function ensurePrivateDirectory(directory) {
|
|
16
|
+
if (process.platform === 'win32') { windowsStorage('ensure-directory', { path: directory }); return; }
|
|
17
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
18
|
+
const info = lstatSync(directory);
|
|
19
|
+
if (!info.isDirectory() || info.isSymbolicLink() || (info.mode & 0o077) || (process.getuid && info.uid !== process.getuid())) throw new Error('driver_private_directory_required');
|
|
20
|
+
}
|
|
21
|
+
export function assertPrivateFile(file, info, limit = 65536) {
|
|
22
|
+
const pathInfo = lstatSync(file);
|
|
23
|
+
if (!info.isFile() || info.nlink !== 1 || info.size > limit || pathInfo.isSymbolicLink() || info.dev !== pathInfo.dev || info.ino !== pathInfo.ino) throw new Error('driver_private_file_required');
|
|
24
|
+
if (process.platform !== 'win32') {
|
|
25
|
+
if ((info.mode & 0o077) || (process.getuid && info.uid !== process.getuid())) throw new Error('driver_private_file_required');
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const signature = `${info.dev}:${info.ino}:${info.ctimeMs}:${info.mtimeMs}:${info.size}`;
|
|
29
|
+
const previous = checked.get(file);
|
|
30
|
+
if (!previous || previous.signature !== signature || Date.now() - previous.at >= 30_000) {
|
|
31
|
+
windowsStorage('assert-file', { path: file });
|
|
32
|
+
if (checked.size > 32) checked.clear();
|
|
33
|
+
checked.set(file, { signature, at: Date.now() });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export function readPrivateJson(file, fallback, limit = 65536) {
|
|
37
|
+
let fd;
|
|
38
|
+
try {
|
|
39
|
+
fd = openSync(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
40
|
+
assertPrivateFile(file, fstatSync(fd), limit);
|
|
41
|
+
return JSON.parse(readFileSync(fd, 'utf8'));
|
|
42
|
+
} catch (error) { if (error.code === 'ENOENT') return fallback; throw error; }
|
|
43
|
+
finally { if (fd !== undefined) closeSync(fd); }
|
|
44
|
+
}
|
|
45
|
+
export function writePrivateJson(file, value, { create = false } = {}) {
|
|
46
|
+
ensurePrivateDirectory(dirname(file));
|
|
47
|
+
const target = create ? file : `${file}.${randomBytes(6).toString('hex')}.tmp`;
|
|
48
|
+
writeFileSync(target, JSON.stringify(value), { mode: 0o600, flag: 'wx' });
|
|
49
|
+
// Windows files inherit the protected directory ACL. Validate before activation.
|
|
50
|
+
if (process.platform === 'win32') {
|
|
51
|
+
secureCreatedPrivateFile(target);
|
|
52
|
+
const fd = openSync(target, constants.O_RDONLY);
|
|
53
|
+
try { assertPrivateFile(target, fstatSync(fd)); } finally { closeSync(fd); }
|
|
54
|
+
}
|
|
55
|
+
if (!create) renameSync(target, file);
|
|
56
|
+
checked.delete(file);
|
|
57
|
+
}
|