device-center 0.2.0-beta.4 → 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 +1 -1
- package/README.md +10 -8
- package/SECURITY.md +8 -2
- package/bin/device-center.mjs +3 -3
- package/docs/agent-install.md +5 -5
- 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 +3 -2
- package/scripts/access.mjs +0 -1
- package/scripts/driver.mjs +2 -3
- package/scripts/service.mjs +42 -16
- package/server/drivers/access.mjs +15 -14
- package/server/drivers/browserPairing.mjs +8 -3
- package/server/drivers/cloudLink.mjs +19 -20
- 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 +26 -6
- package/server/unifiedAccess.mjs +24 -4
- package/src/api.js +2 -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 +13 -10
- package/src/main.js +6 -5
- package/src/messages.js +215 -117
- package/src/onboarding.js +35 -15
- package/src/start.js +10 -2
- package/start.html +12 -4
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);
|
|
@@ -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
19
|
if (target.pathname !== '/pair' || target.search || !/^#ticket=[a-f0-9]{64}&expires=\d+$/.test(target.hash)) throw new Error('无效的浏览器交接。');
|
|
17
|
-
const
|
|
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 手动配对。')); });
|
|
@@ -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) };
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { win32 } from 'node:path';
|
|
3
|
+
|
|
4
|
+
// Fixed PowerShell code; paths and secrets travel only on stdin, never in arguments.
|
|
5
|
+
// No execution-policy change, elevation, password, external download or shell expansion.
|
|
6
|
+
export const powershellPath = (environment = process.env) => {
|
|
7
|
+
const systemRoot = environment.SystemRoot;
|
|
8
|
+
if (!systemRoot || !win32.isAbsolute(systemRoot) || /[\r\n\0]/.test(systemRoot)) throw new Error('windows_system_root_required');
|
|
9
|
+
return win32.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
|
|
10
|
+
};
|
|
11
|
+
export const powershellArguments = source => ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(source, 'utf16le').toString('base64')];
|
|
12
|
+
export function runPowerShell(source, input, { spawnImpl = spawnSync, environment = process.env } = {}) {
|
|
13
|
+
const result = spawnImpl(powershellPath(environment), powershellArguments(source), {
|
|
14
|
+
input: JSON.stringify(input), encoding: 'utf8', windowsHide: true, shell: false, timeout: 15_000, maxBuffer: 512 * 1024,
|
|
15
|
+
});
|
|
16
|
+
// PowerShell diagnostics can contain input values. Never relay them to the caller.
|
|
17
|
+
if (result.error || result.status !== 0) throw new Error('windows_security_operation_failed');
|
|
18
|
+
try { return JSON.parse(result.stdout.replace(/^\uFEFF/, '').trim()); }
|
|
19
|
+
catch { throw new Error('windows_security_response_invalid'); }
|
|
20
|
+
}
|
|
21
|
+
export const powershellPreamble = `
|
|
22
|
+
$ErrorActionPreference = 'Stop'
|
|
23
|
+
# A Node child launched from PowerShell 7 inherits its module search path.
|
|
24
|
+
# Windows PowerShell 5.1 must load its own built-in modules (including Set-Acl),
|
|
25
|
+
# not incompatible PS7 or user-installed modules. This affects only this child.
|
|
26
|
+
$env:PSModulePath = [IO.Path]::Combine($PSHOME, 'Modules')
|
|
27
|
+
[Console]::InputEncoding = [System.Text.UTF8Encoding]::new($false)
|
|
28
|
+
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
|
|
29
|
+
$request = [Console]::In.ReadToEnd() | ConvertFrom-Json
|
|
30
|
+
$sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User
|
|
31
|
+
`;
|
|
32
|
+
export const windowsStorageSource = powershellPreamble + `
|
|
33
|
+
function Assert-PlainPath([string] $path) {
|
|
34
|
+
$cursor = [IO.Path]::GetFullPath($path)
|
|
35
|
+
while ($cursor) {
|
|
36
|
+
if ([IO.File]::Exists($cursor) -or [IO.Directory]::Exists($cursor)) {
|
|
37
|
+
$item = Get-Item -LiteralPath $cursor -Force
|
|
38
|
+
if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'reparse_point' }
|
|
39
|
+
}
|
|
40
|
+
$parent = [IO.Path]::GetDirectoryName($cursor)
|
|
41
|
+
if ($parent -eq $cursor) { break }
|
|
42
|
+
$cursor = $parent
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function Assert-Private([string] $path) {
|
|
46
|
+
Assert-PlainPath $path
|
|
47
|
+
$acl = Get-Acl -LiteralPath $path
|
|
48
|
+
if ($acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value -ne $sid.Value) { throw 'owner_required' }
|
|
49
|
+
$currentAllowed = $false
|
|
50
|
+
foreach ($rule in $acl.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])) {
|
|
51
|
+
if ($rule.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Allow) { throw 'deny_rule' }
|
|
52
|
+
if ($rule.IdentityReference.Value -notin @($sid.Value, 'S-1-5-18')) { throw 'private_acl_required' }
|
|
53
|
+
if ($rule.IdentityReference.Value -eq $sid.Value -and ($rule.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::FullControl) -eq [System.Security.AccessControl.FileSystemRights]::FullControl) { $currentAllowed = $true }
|
|
54
|
+
}
|
|
55
|
+
if (-not $currentAllowed) { throw 'current_user_access_required' }
|
|
56
|
+
}
|
|
57
|
+
switch ($request.operation) {
|
|
58
|
+
'ensure-directory' {
|
|
59
|
+
Assert-PlainPath $request.path
|
|
60
|
+
if (-not [IO.Directory]::Exists($request.path)) {
|
|
61
|
+
$directory = [IO.Directory]::CreateDirectory($request.path)
|
|
62
|
+
$acl = New-Object System.Security.AccessControl.DirectorySecurity
|
|
63
|
+
$acl.SetOwner($sid)
|
|
64
|
+
$acl.SetAccessRuleProtection($true, $false)
|
|
65
|
+
foreach ($principal in @($sid.Value, 'S-1-5-18')) {
|
|
66
|
+
$identity = [System.Security.Principal.SecurityIdentifier]::new($principal)
|
|
67
|
+
$rule = [System.Security.AccessControl.FileSystemAccessRule]::new($identity, 'FullControl', 'ContainerInherit, ObjectInherit', 'None', 'Allow')
|
|
68
|
+
$acl.AddAccessRule($rule)
|
|
69
|
+
}
|
|
70
|
+
Set-Acl -LiteralPath $directory.FullName -AclObject $acl
|
|
71
|
+
}
|
|
72
|
+
Assert-Private $request.path
|
|
73
|
+
@{ ok = $true } | ConvertTo-Json -Compress
|
|
74
|
+
}
|
|
75
|
+
'assert-file' {
|
|
76
|
+
Assert-Private ([IO.Path]::GetDirectoryName($request.path))
|
|
77
|
+
Assert-Private $request.path
|
|
78
|
+
@{ ok = $true } | ConvertTo-Json -Compress
|
|
79
|
+
}
|
|
80
|
+
'secure-new-file' {
|
|
81
|
+
# Called only after our exclusive wx creation in an already protected directory.
|
|
82
|
+
Assert-Private ([IO.Path]::GetDirectoryName($request.path))
|
|
83
|
+
Assert-PlainPath $request.path
|
|
84
|
+
$acl = [System.Security.AccessControl.FileSecurity]::new()
|
|
85
|
+
$acl.SetOwner($sid)
|
|
86
|
+
$acl.SetAccessRuleProtection($true, $false)
|
|
87
|
+
foreach ($principal in @($sid.Value, 'S-1-5-18')) {
|
|
88
|
+
$identity = [System.Security.Principal.SecurityIdentifier]::new($principal)
|
|
89
|
+
$acl.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new($identity, 'FullControl', 'Allow'))
|
|
90
|
+
}
|
|
91
|
+
Set-Acl -LiteralPath $request.path -AclObject $acl
|
|
92
|
+
Assert-Private $request.path
|
|
93
|
+
@{ ok = $true } | ConvertTo-Json -Compress
|
|
94
|
+
}
|
|
95
|
+
'protect' {
|
|
96
|
+
Add-Type -AssemblyName System.Security
|
|
97
|
+
$data = [Convert]::FromBase64String($request.data)
|
|
98
|
+
$encrypted = [System.Security.Cryptography.ProtectedData]::Protect($data, $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser)
|
|
99
|
+
@{ data = [Convert]::ToBase64String($encrypted) } | ConvertTo-Json -Compress
|
|
100
|
+
[Array]::Clear($data, 0, $data.Length)
|
|
101
|
+
}
|
|
102
|
+
'unprotect' {
|
|
103
|
+
Add-Type -AssemblyName System.Security
|
|
104
|
+
$data = [System.Security.Cryptography.ProtectedData]::Unprotect([Convert]::FromBase64String($request.data), $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser)
|
|
105
|
+
@{ data = [Convert]::ToBase64String($data) } | ConvertTo-Json -Compress
|
|
106
|
+
[Array]::Clear($data, 0, $data.Length)
|
|
107
|
+
}
|
|
108
|
+
default { throw 'unknown_storage_operation' }
|
|
109
|
+
}
|
|
110
|
+
`;
|
|
111
|
+
export function windowsStorage(operation, values) {
|
|
112
|
+
if (process.platform !== 'win32') throw new Error('windows_only');
|
|
113
|
+
return runPowerShell(windowsStorageSource, { operation, ...values });
|
|
114
|
+
}
|
|
115
|
+
export const protectWindowsIdentity = value => ({ format: 'device-center-dpapi-v1', data: windowsStorage('protect', { data: Buffer.from(JSON.stringify(value)).toString('base64') }).data });
|
|
116
|
+
export function unprotectWindowsIdentity(value) {
|
|
117
|
+
if (value?.format !== 'device-center-dpapi-v1' || typeof value.data !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(value.data)) throw new Error('windows_encrypted_identity_required');
|
|
118
|
+
return JSON.parse(Buffer.from(windowsStorage('unprotect', { data: value.data }).data, 'base64').toString('utf8'));
|
|
119
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { powershellArguments, powershellPath, powershellPreamble, runPowerShell } from './windowsSecurity.mjs';
|
|
3
|
+
|
|
4
|
+
const literal = value => `'${String(value).replaceAll("'", "''")}'`;
|
|
5
|
+
export function windowsServiceDefinition({ home, root, node, script, port, dbPath, logPath, errorLogPath, environment = process.env }) {
|
|
6
|
+
const launcher = `$ErrorActionPreference = 'Stop'\n$env:PORT = '${port}'\n$env:DEVICE_CENTER_DB_PATH = ${literal(dbPath)}\n$env:DEVICE_CENTER_SERVICE_MANAGER = 'windows-task'\n$env:DEVICE_CENTER_RUNTIME = 'native'\n$env:DEVICE_CENTER_PUBLIC_ORIGIN = $null\n$env:DEVICE_CENTER_AUTH_FILE = $null\nSet-Location -LiteralPath ${literal(root)}\n# Windows PowerShell 5.1 treats redirected native stderr as ErrorRecord.\n# Keep Node warnings visible in the log; use its exit code for task failure.\n$ErrorActionPreference = 'Continue'\n$LASTEXITCODE = 1\n& ${literal(node)} ${literal(script)} >> ${literal(logPath)} 2>> ${literal(errorLogPath)}\n$exitCode = $LASTEXITCODE\n$ErrorActionPreference = 'Stop'\nexit $exitCode\n`;
|
|
7
|
+
return {
|
|
8
|
+
taskName: `DeviceCenter-${createHash('sha256').update(home.toLowerCase()).digest('hex').slice(0, 16)}`,
|
|
9
|
+
executable: powershellPath(environment),
|
|
10
|
+
arguments: ['-WindowStyle', 'Hidden', ...powershellArguments(launcher)].join(' '),
|
|
11
|
+
workingDirectory: root, logonType: 'InteractiveToken', runLevel: 'Limited', trigger: 'CurrentUserLogon',
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
// COM Task Scheduler is built into Windows. Only this user's exact task is touched.
|
|
15
|
+
export const windowsTaskSource = powershellPreamble + `
|
|
16
|
+
$scheduler = New-Object -ComObject 'Schedule.Service'
|
|
17
|
+
$scheduler.Connect()
|
|
18
|
+
$folder = $scheduler.GetFolder('\\')
|
|
19
|
+
$task = $null
|
|
20
|
+
try { $task = $folder.GetTask($request.task.taskName) } catch {
|
|
21
|
+
$exception = $_.Exception
|
|
22
|
+
while ($exception.InnerException) { $exception = $exception.InnerException }
|
|
23
|
+
if ($exception.HResult -ne -2147024894) { throw }
|
|
24
|
+
}
|
|
25
|
+
function Assert-OurTask {
|
|
26
|
+
if (-not $task) { throw 'task_missing' }
|
|
27
|
+
$definition = $task.Definition
|
|
28
|
+
$principal = $definition.Principal
|
|
29
|
+
$principalSid = $principal.UserId
|
|
30
|
+
if ($principal.UserId -ne $sid.Value) { $principalSid = ([System.Security.Principal.NTAccount]::new($principal.UserId)).Translate([System.Security.Principal.SecurityIdentifier]).Value }
|
|
31
|
+
if ($principalSid -ne $sid.Value -or $principal.LogonType -ne 3 -or $principal.RunLevel -ne 0) { throw 'task_principal_changed' }
|
|
32
|
+
if ($definition.Actions.Count -ne 1 -or $definition.Triggers.Count -ne 1) { throw 'task_changed' }
|
|
33
|
+
$action = $definition.Actions.Item(1)
|
|
34
|
+
$trigger = $definition.Triggers.Item(1)
|
|
35
|
+
if ($action.Type -ne 0 -or $action.Path -ne $request.task.executable -or $action.Arguments -cne $request.task.arguments -or $action.WorkingDirectory -cne $request.task.workingDirectory -or $trigger.Type -ne 9 -or $trigger.UserId -ne $sid.Value) { throw 'task_action_changed' }
|
|
36
|
+
}
|
|
37
|
+
switch ($request.operation) {
|
|
38
|
+
'install' {
|
|
39
|
+
if ($task) { throw 'task_already_exists' }
|
|
40
|
+
$definition = $scheduler.NewTask(0)
|
|
41
|
+
$definition.RegistrationInfo.Description = 'Device Center: current user local Driver; login startup only.'
|
|
42
|
+
$definition.Principal.UserId = $sid.Value
|
|
43
|
+
$definition.Principal.LogonType = 3
|
|
44
|
+
$definition.Principal.RunLevel = 0
|
|
45
|
+
$trigger = $definition.Triggers.Create(9)
|
|
46
|
+
$trigger.UserId = $sid.Value
|
|
47
|
+
$action = $definition.Actions.Create(0)
|
|
48
|
+
$action.Path = $request.task.executable
|
|
49
|
+
$action.Arguments = $request.task.arguments
|
|
50
|
+
$action.WorkingDirectory = $request.task.workingDirectory
|
|
51
|
+
$definition.Settings.Enabled = $true
|
|
52
|
+
$definition.Settings.AllowDemandStart = $true
|
|
53
|
+
$definition.Settings.StartWhenAvailable = $true
|
|
54
|
+
$definition.Settings.DisallowStartIfOnBatteries = $false
|
|
55
|
+
$definition.Settings.StopIfGoingOnBatteries = $false
|
|
56
|
+
$definition.Settings.ExecutionTimeLimit = 'PT0S'
|
|
57
|
+
$definition.Settings.MultipleInstances = 2
|
|
58
|
+
$definition.Settings.RestartInterval = 'PT1M'
|
|
59
|
+
$definition.Settings.RestartCount = 3
|
|
60
|
+
$task = $folder.RegisterTaskDefinition($request.task.taskName, $definition, 2, $sid.Value, $null, 3)
|
|
61
|
+
$null = $task.Run($null)
|
|
62
|
+
@{ registered = $true } | ConvertTo-Json -Compress
|
|
63
|
+
}
|
|
64
|
+
'status' {
|
|
65
|
+
if ($task) { Assert-OurTask }
|
|
66
|
+
@{ exists = [bool]$task; state = $(if ($task) { [int]$task.State } else { $null }) } | ConvertTo-Json -Compress
|
|
67
|
+
}
|
|
68
|
+
'stop' { Assert-OurTask; $task.Stop(0); @{ stopped = $true } | ConvertTo-Json -Compress }
|
|
69
|
+
'remove' { Assert-OurTask; $folder.DeleteTask($request.task.taskName, 0); @{ removed = $true } | ConvertTo-Json -Compress }
|
|
70
|
+
default { throw 'unknown_task_operation' }
|
|
71
|
+
}
|
|
72
|
+
`;
|
|
73
|
+
export function windowsTask(operation, task) {
|
|
74
|
+
if (process.platform !== 'win32') throw new Error('windows_only');
|
|
75
|
+
try { return runPowerShell(windowsTaskSource, { operation, task }); }
|
|
76
|
+
catch { throw new Error('Windows 当前用户任务操作失败。请检查任务计划程序、组策略或既有任务;未提权、改策略或覆盖任务。可改用 start 前台模式。'); }
|
|
77
|
+
}
|