device-center 0.1.0-beta.1 → 0.2.0-beta.2

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.
Files changed (47) hide show
  1. package/CONTRIBUTING.md +1 -1
  2. package/Dockerfile +2 -2
  3. package/README.md +37 -127
  4. package/SECURITY.md +7 -15
  5. package/bin/device-center.mjs +6 -3
  6. package/docs/agent-install.md +105 -0
  7. package/docs/architecture.md +25 -57
  8. package/docs/connection-routing.md +1 -0
  9. package/docs/device-profiles.md +56 -0
  10. package/docs/driver-binding.md +6 -1
  11. package/docs/friend-trial.md +42 -0
  12. package/docs/hosted-access.md +52 -0
  13. package/docs/npm.md +19 -35
  14. package/docs/onboarding.md +8 -5
  15. package/docs/remote-access.md +76 -0
  16. package/docs/roadmap.md +17 -28
  17. package/docs/security-and-sync.md +9 -64
  18. package/login.html +1 -1
  19. package/npm-shrinkwrap.json +304 -0
  20. package/package.json +22 -4
  21. package/scripts/access.mjs +48 -0
  22. package/scripts/driver.mjs +4 -3
  23. package/scripts/release-check.mjs +4 -0
  24. package/server/cloudAccess.mjs +13 -6
  25. package/server/connector.mjs +11 -5
  26. package/server/drivers/access.mjs +84 -0
  27. package/server/drivers/certificates.mjs +32 -0
  28. package/server/drivers/cloudLink.mjs +46 -4
  29. package/server/drivers/cloudRegistry.mjs +107 -20
  30. package/server/drivers/deviceProfiles.mjs +47 -0
  31. package/server/drivers/driverProtocol.mjs +3 -2
  32. package/server/drivers/inventory.mjs +21 -2
  33. package/server/drivers/relay.mjs +59 -0
  34. package/server/drivers/remote.mjs +111 -0
  35. package/server/index.mjs +44 -13
  36. package/server/mcp.mjs +5 -3
  37. package/server/unifiedAccess.mjs +33 -17
  38. package/src/api.js +4 -1
  39. package/src/device-state.js +19 -0
  40. package/src/login.css +4 -0
  41. package/src/login.js +1 -0
  42. package/src/main.js +197 -42
  43. package/src/onboarding.js +62 -0
  44. package/src/start.css +47 -0
  45. package/src/start.js +23 -0
  46. package/src/styles.css +22 -0
  47. package/start.html +45 -0
@@ -0,0 +1,111 @@
1
+ import { Duplex } from 'node:stream';
2
+ import { Server, connect } from 'node:tls';
3
+ import { createServer as createNetServer } from 'node:net';
4
+ import { signedRequest, object } from './driverProtocol.mjs';
5
+ import { cloudPost, readLink } from './cloudLink.mjs';
6
+ import { certificateName } from './certificates.mjs';
7
+ import { accessFile, readAccess, peerFingerprint, readGrantedLog, lanEndpoint, audit } from './access.mjs';
8
+ import { dirname, join } from 'node:path';
9
+
10
+ export const machinePost = (state, action, payload = {}, fetchImpl) => cloudPost(state.origin, `/api/driver/${action}`, signedRequest(state.privateKey,state.origin,`/api/driver/${action}`,state.driverId,payload),fetchImpl);
11
+ export async function remotePeers(state, fetchImpl) { return (await machinePost(state,'peers',{},fetchImpl)).peers; }
12
+ class RelayStream extends Duplex {
13
+ constructor(state, channelId, fetchImpl) {
14
+ super({ autoDestroy: false }); this.state=state;this.channelId=channelId;this.fetchImpl=fetchImpl;this.pending=[];this.deadline=Date.now()+60000;this.on('error',()=>{});this.timer=setTimeout(()=>void this.tick(),0);
15
+ }
16
+ _read() {}
17
+ _write(chunk, encoding, callback) {
18
+ if (this.pending.length + Math.ceil(chunk.length/4096) > 32) return callback(new Error('relay_backpressure'));
19
+ for (let at=0;at<chunk.length;at+=4096) this.pending.push(chunk.subarray(at,at+4096).toString('base64'));
20
+ callback();
21
+ }
22
+ _final(callback) { callback(); }
23
+ _destroy(error, callback) { clearTimeout(this.timer);void machinePost(this.state,'relay/close',{channelId:this.channelId},this.fetchImpl).catch(()=>{});callback(error); }
24
+ async tick() {
25
+ if(this.destroyed)return;
26
+ try {
27
+ if(Date.now()>this.deadline)throw new Error('relay_session_expired');
28
+ const answer=await machinePost(this.state,'relay/exchange',{channelId:this.channelId,frames:this.pending.splice(0,2)},this.fetchImpl);
29
+ for(const frame of answer.frames) { if(!this.push(Buffer.from(frame,'base64'))) { /* bounded total TLS response, max 96 KiB */ } }
30
+ if(this.readableLength>131072)throw new Error('relay_backpressure');
31
+ if(!this.destroyed)this.timer=setTimeout(()=>void this.tick(),500);
32
+ }catch(error){this.destroy(error);}
33
+ }
34
+ }
35
+ export function createTargetTls(state, file, route, fetchImpl) {
36
+ const grants=readAccess(file).grants.filter(g=>g.expiresAt>Date.now());
37
+ const server=new Server({key:state.privateKey,cert:state.certificate,ca:grants.map(g=>g.certificate),minVersion:'TLSv1.3',handshakeTimeout:3000,requestCert:true,rejectUnauthorized:true});
38
+ server.on('tlsClientError',()=>{});
39
+ server.on('secureConnection', socket=>{
40
+ let buffer='', done=false;
41
+ socket.setTimeout(20000,()=>socket.destroy());socket.on('error',()=>{});
42
+ socket.on('data',async chunk=>{
43
+ if(done)return;
44
+ buffer+=chunk.toString('utf8');if(Buffer.byteLength(buffer)>2048){done=true;socket.destroy();return;}
45
+ if(!buffer.includes('\n'))return;
46
+ done=true;
47
+ try {
48
+ if(buffer.indexOf('\n')!==buffer.length-1)throw new Error('access_invalid_operation');
49
+ const fingerprint=peerFingerprint(socket);
50
+ if (!(await remotePeers(state,fetchImpl)).some(p=>p.fingerprint===fingerprint)) throw new Error('access_peer_revoked');
51
+ const result=readGrantedLog({file,fingerprint,request:JSON.parse(buffer),route});socket.end(JSON.stringify({ok:true,result})+'\n');
52
+ }catch(error){socket.end(JSON.stringify({ok:false,error:/^access_[a-z_]+$/.test(error.message)?error.message:'access_read_failed'})+'\n');}
53
+ });
54
+ });
55
+ return server;
56
+ }
57
+ export async function requestTls(state, peer, request, options, route) {
58
+ return new Promise((resolve,reject)=>{
59
+ const socket=connect({ ...options,key:state.privateKey,cert:state.certificate,ca:[peer.certificate],servername:certificateName(peer.fingerprint),minVersion:'TLSv1.3',rejectUnauthorized:true });
60
+ let timeout=setTimeout(()=>socket.destroy(new Error('access_request_timeout')), options.socket?30000:3000);
61
+ let buffer='', settled=false;
62
+ function finish(error,value){if(settled)return;settled=true;clearTimeout(timeout);socket.destroy();options.socket?.destroy();error?reject(error):resolve(value);}
63
+ socket.once('secureConnect',()=>{
64
+ try{clearTimeout(timeout);timeout=setTimeout(()=>socket.destroy(new Error('access_response_timeout')),30000);if(peerFingerprint(socket)!==peer.fingerprint)throw new Error('access_peer_mismatch');socket.write(JSON.stringify(request)+'\n');}catch(e){finish(e);}
65
+ });
66
+ socket.on('data',chunk=>{buffer+=chunk.toString('utf8');if(Buffer.byteLength(buffer)>100000)return finish(new Error('access_response_too_large'));if(buffer.includes('\n')){try{const reply=JSON.parse(buffer);if(!reply.ok)throw new Error(reply.error);finish(null,{...reply.result,route});}catch(e){finish(e);}}});
67
+ socket.once('error',e=>finish(e));socket.once('end',()=>{if(!settled)finish(new Error('access_response_incomplete'));});
68
+ });
69
+ }
70
+ export function createRemoteClient({ linkFile, fetchImpl, dial = requestTls }) {
71
+ return {
72
+ async peers(){const state=readLink(linkFile);if(!state?.driverId||state.disabled)throw new Error('driver_not_bound');return {peers:(await remotePeers(state,fetchImpl)).map(({certificate,...p})=>p)};},
73
+ async read(input){
74
+ object(input,['fingerprint','label','bytes']);
75
+ const state=readLink(linkFile);if(!state?.driverId||state.disabled||!state.certificate)throw new Error('driver_secure_identity_required');
76
+ const file=accessFile(linkFile),peer=readAccess(file).trusts.find(p=>p.fingerprint===input.fingerprint);
77
+ if(!peer)throw new Error('access_peer_not_trusted');
78
+ const request={operation:'read-log',label:input.label,bytes:input.bytes??16384};
79
+ if(typeof request.label!=='string'||!/^[a-zA-Z0-9_-]{1,40}$/.test(request.label)||!Number.isInteger(request.bytes)||request.bytes<1||request.bytes>65536)throw new Error('access_invalid_operation');
80
+ // Directory lookup proves current account membership and revocation before either route.
81
+ const current=(await remotePeers(state,fetchImpl)).find(p=>p.id===peer.id&&p.fingerprint===peer.fingerprint);
82
+ if(!current)throw new Error('relay_peer_unavailable');
83
+ let fallbackReason=null,result;
84
+ if(peer.lan){try{const endpoint=lanEndpoint(peer.lan);result=await dial(state,peer,request,endpoint,'lan-direct');}catch(e){if(!['ECONNREFUSED','ETIMEDOUT','EHOSTUNREACH','ENETUNREACH','access_request_timeout'].includes(e.code??e.message))throw e;fallbackReason='approved_lan_unreachable';}}
85
+ if(!result){const {channelId}=await machinePost(state,'relay/open',{targetId:peer.id},fetchImpl);const stream=new RelayStream(state,channelId,fetchImpl);result=await dial(state,peer,request,{socket:stream},'cloud-relay');}
86
+ audit(join(dirname(file),'access-audit.jsonl'),{fingerprint:peer.fingerprint,label:request.label,route:result.route,bytes:result.bytes,outcome:'received'});
87
+ return {...result,fallbackReason};
88
+ },
89
+ };
90
+ }
91
+ export function createRemoteAgent({ linkFile, fetchImpl }) {
92
+ let stopped=true,timer,listener,listenerConfig='',polling=false;const streams=new Set(),status={state:'disabled',error:null,lan:null};
93
+ async function tick(){
94
+ if(stopped||polling)return;polling=true;
95
+ try{
96
+ const state=readLink(linkFile),file=accessFile(linkFile),access=readAccess(file);
97
+ if(!state?.driverId||state.disabled||!state.certificate){listener?.close();listener=null;listenerConfig='';status.lan=null;for(const stream of streams)stream.destroy();status.state='disabled';return;}
98
+ const grants=access.grants.filter(g=>g.expiresAt>Date.now());
99
+ const wanted=grants.length&&access.listen?JSON.stringify(access.listen):'';
100
+ if(wanted!==listenerConfig){listener?.close();listener=null;listenerConfig=wanted;status.lan=null;
101
+ if(wanted){const endpoint=lanEndpoint(access.listen);listener=createNetServer(socket=>{const tls=createTargetTls(state,file,'lan-direct',fetchImpl);tls.emit('connection',socket);socket.once('close',()=>tls.close());});listener.maxConnections=4;listener.on('error',()=>{status.error='access_lan_listen_failed';listenerConfig='';});listener.listen(endpoint.port,endpoint.host,()=>{status.lan=endpoint;});}}
102
+ if(!grants.length){status.state='no-active-grants';return;}
103
+ const {incoming}=await machinePost(state,'relay/accept',{},fetchImpl);
104
+ status.state='waiting';status.error=null;
105
+ if(incoming){const allowed=grants.some(g=>g.fingerprint===incoming.fingerprint&&g.certificate===incoming.certificate);if(!allowed){await machinePost(state,'relay/close',{channelId:incoming.channelId},fetchImpl);return;}
106
+ const stream=new RelayStream(state,incoming.channelId,fetchImpl),tls=createTargetTls(state,file,'cloud-relay',fetchImpl);streams.add(stream);stream.once('close',()=>{streams.delete(stream);tls.close();});tls.emit('connection',stream);}
107
+ }catch(e){status.error=/^(driver_|access_|relay_)[a-z_]+$/.test(e.message)?e.message:'driver_cloud_unavailable';status.state='retrying';}
108
+ finally{polling=false;if(!stopped)timer=setTimeout(()=>void tick(),3000);}
109
+ }
110
+ return {status:()=>({...status}),start(){if(stopped){stopped=false;void tick();}},stop(){stopped=true;clearTimeout(timer);listener?.close();for(const stream of streams)stream.destroy();}};
111
+ }
package/server/index.mjs CHANGED
@@ -9,9 +9,11 @@ import { createConnector, PROTOCOL_VERSIONS, TOOLS } from './connector.mjs';
9
9
  import { createCloudAccess } from './cloudAccess.mjs';
10
10
  import { createCloudRegistry } from './drivers/cloudRegistry.mjs';
11
11
  import { createPublisher, defaultLinkFile, linkSummary, readLink } from './drivers/cloudLink.mjs';
12
+ import { createRemoteAgent, createRemoteClient } from './drivers/remote.mjs';
12
13
 
13
14
  const PROJECT_ROOT = resolve(fileURLToPath(new URL('..', import.meta.url)));
14
15
  const MIME_TYPES = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png' };
16
+ const PUBLIC_GUIDES = Object.freeze({ '/install/agent.md': 'docs/agent-install.md', '/install/trial.md': 'docs/friend-trial.md', '/install/remote-access.md': 'docs/remote-access.md', '/install/device-profiles.md': 'docs/device-profiles.md', '/install/troubleshooting.md': 'docs/troubleshooting.md' });
15
17
  function sendJson(response, status, payload, headers = {}) {
16
18
  response.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff', ...headers });
17
19
  response.end(JSON.stringify(payload));
@@ -41,6 +43,7 @@ async function serveStatic(pathname, response) {
41
43
  let filePath;
42
44
  if (pathname === '/' || pathname === '/index.html') filePath = resolve(PROJECT_ROOT, 'index.html');
43
45
  else if (pathname === '/login') filePath = resolve(PROJECT_ROOT, 'login.html');
46
+ else if (pathname === '/start' || pathname === '/start.html') filePath = resolve(PROJECT_ROOT, 'start.html');
44
47
  else if (pathname.startsWith('/src/')) {
45
48
  filePath = resolve(PROJECT_ROOT, `.${pathname}`);
46
49
  if (!filePath.startsWith(resolve(PROJECT_ROOT, 'src') + '/')) return sendJson(response, 404, { error: 'not_found' });
@@ -62,21 +65,34 @@ export function createDeviceCenterServer({ dbPath = './data/device-center.sqlite
62
65
  const cloud = Boolean(cloudAccess);
63
66
  const absoluteDbPath = resolve(dbPath);
64
67
  const db = openDatabase(absoluteDbPath);
65
- const principal = cloud ? { tenantId: 'cloud-owner', subject: 'owner' } : currentPrincipal();
66
68
  const startedAt = Date.now();
67
69
  let lastInitializeAt = null;
68
- const inventory = createInventory({ db, tenantId: principal.tenantId, runtime: cloud ? 'container' : runtime, sshHome, sshProbe });
69
- const registry = cloud ? createCloudRegistry({ db, origin: publicOrigin }) : null;
70
+ let inventory, registry;
71
+ try {
72
+ inventory = cloud ? null : createInventory({ db, tenantId: currentPrincipal().tenantId, runtime, sshHome, sshProbe });
73
+ registry = cloud ? createCloudRegistry({ db, origin: publicOrigin, multiUser: cloudAccess.accountMode === 'multi-user', legacyOwnerMapped: cloudAccess.legacyOwnerMapped }) : null;
74
+ } catch (cause) { db.close(); throw cause; }
70
75
  const publisher = !cloud && runtime === 'native' && cloudLinkFile ? createPublisher({ file: cloudLinkFile, inventory, fetchImpl: driverFetch }) : null;
71
- const connector = createConnector({ inventory, transport: 'Streamable HTTP', onInitialize: () => { lastInitializeAt = Date.now(); } });
76
+ const remoteAgent = !cloud && runtime === 'native' && cloudLinkFile ? createRemoteAgent({ linkFile: cloudLinkFile, fetchImpl: driverFetch }) : null;
77
+ const remoteClient = remoteAgent ? createRemoteClient({ linkFile: cloudLinkFile, fetchImpl: driverFetch }) : null;
78
+ const connector = cloud ? null : createConnector({ inventory, remoteClient, transport: 'Streamable HTTP', onInitialize: () => { lastInitializeAt = Date.now(); } });
72
79
  const server = createHttpServer(async (request, response) => {
73
80
  try {
74
81
  const origin = cloudAccess ? cloudAccess.validate(request) : validateLocalRequest(request);
75
82
  const url = new URL(request.url ?? '/', origin);
76
83
  const path = url.pathname;
84
+ // Public static installation reference only. No identity, configuration or owner API.
85
+ if (['/start', '/start.html', '/src/start.js', '/src/start.css', '/src/onboarding.js'].includes(path) && request.method === 'GET') return await serveStatic(path, response);
86
+ if (Object.hasOwn(PUBLIC_GUIDES, path) && request.method === 'GET') {
87
+ const body = await readFile(resolve(PROJECT_ROOT, PUBLIC_GUIDES[path]));
88
+ response.writeHead(200, { 'content-type': 'text/markdown; charset=utf-8', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff', 'content-security-policy': "default-src 'none'; frame-ancestors 'none'", 'referrer-policy': 'no-referrer' });
89
+ return response.end(body);
90
+ }
91
+ let accountRegistry;
77
92
  if (cloud) {
78
93
  const machineActions = { '/api/driver/pair/start': 'start', '/api/driver/pair/poll': 'poll', '/api/driver/report': 'report', '/api/driver/disconnect': 'disconnect' };
79
94
  if (Object.hasOwn(machineActions, path) && request.method === 'POST') return sendJson(response, 200, registry[machineActions[path]](await readJson(request)));
95
+ if (['control','certificate','peers','relay/open','relay/accept','relay/exchange','relay/close'].some(action => path === `/api/driver/${action}`) && request.method === 'POST') return sendJson(response, 200, registry.machine(path.slice('/api/driver/'.length), await readJson(request)));
80
96
  if (['/login', '/src/login.js', '/src/login.css'].includes(path) && request.method === 'GET') return await serveStatic(path, response);
81
97
  if (path === '/api/auth/options' && request.method === 'GET') return sendJson(response, 200, cloudAccess.options(request));
82
98
  if (path === '/api/auth/continue' && request.method === 'POST') {
@@ -97,15 +113,17 @@ export function createDeviceCenterServer({ dbPath = './data/device-center.sqlite
97
113
  if (request.headers.origin !== origin) return sendJson(response, 403, { error: 'same_origin_required' });
98
114
  return sendJson(response, 200, { ok: true }, { 'set-cookie': cloudAccess.logout(request) });
99
115
  }
100
- if (!await cloudAccess.authenticated(request)) {
116
+ const principal = await cloudAccess.resolvePrincipal(request);
117
+ if (!principal) {
101
118
  if (path === '/' && request.method === 'GET') { response.writeHead(303, { location: '/login', 'cache-control': 'no-store' }); return response.end(); }
102
119
  return sendJson(response, 401, { error: 'authentication_required' });
103
120
  }
104
- if (path === '/api/drivers' && request.method === 'GET') return sendJson(response, 200, { drivers: registry.bindings() });
105
- const ownerActions = { '/api/drivers/pairing/lookup': 'lookup', '/api/drivers/pairing/approve': 'approve', '/api/drivers/revoke': 'revoke' };
121
+ accountRegistry = registry.forTenant(principal.tenantId);
122
+ if (path === '/api/drivers' && request.method === 'GET') return sendJson(response, 200, { drivers: accountRegistry.bindings() });
123
+ const ownerActions = { '/api/devices/profile': 'saveProfile', '/api/devices/collect': 'collect', '/api/drivers/pairing/lookup': 'lookup', '/api/drivers/pairing/approve': 'approve', '/api/drivers/revoke': 'revoke' };
106
124
  if (Object.hasOwn(ownerActions, path) && request.method === 'POST') {
107
125
  if (request.headers.origin !== origin) return sendJson(response, 403, { error: 'same_origin_required' });
108
- return sendJson(response, 200, registry[ownerActions[path]](await readJson(request)));
126
+ return sendJson(response, 200, accountRegistry[ownerActions[path]](await readJson(request)));
109
127
  }
110
128
  if (path === '/api/driver/download' && request.method === 'GET') {
111
129
  let bundle; try { bundle = await readFile(driverBundlePath); } catch { return sendJson(response, 404, { error: 'driver_bundle_unavailable' }); }
@@ -127,7 +145,7 @@ export function createDeviceCenterServer({ dbPath = './data/device-center.sqlite
127
145
  if (cause.message === 'invalid_json') return sendJson(response, 400, { jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } });
128
146
  throw cause;
129
147
  }
130
- const answer = connector(message);
148
+ const answer = await connector(message);
131
149
  if (answer === undefined) { response.writeHead(202, { 'cache-control': 'no-store' }); return response.end(); }
132
150
  return sendJson(response, answer.error?.code === -32600 ? 400 : 200, answer);
133
151
  }
@@ -135,6 +153,14 @@ export function createDeviceCenterServer({ dbPath = './data/device-center.sqlite
135
153
  if (request.method !== 'GET' || !web) return sendJson(response, 404, { error: 'not_found' });
136
154
  return await serveStatic(path, response);
137
155
  }
156
+ if (!cloud && ['/api/devices/profile', '/api/devices/collect'].includes(path) && request.method === 'POST') {
157
+ if (request.headers.origin !== origin) return sendJson(response, 403, { error: 'same_origin_required' });
158
+ const input = await readJson(request);
159
+ if (path === '/api/devices/profile') return sendJson(response, 200, inventory.saveProfile(input));
160
+ const result = await inventory.collect(input);
161
+ if (result.device.source === 'local-runtime') publisher?.requestCollection();
162
+ return sendJson(response, 200, result);
163
+ }
138
164
  if (path === '/api/devices/ssh' && request.method === 'POST') {
139
165
  if (request.headers.origin !== origin) return sendJson(response, 403, { error: 'same_origin_required' });
140
166
  return sendJson(response, 200, inventory.associate(await readJson(request)));
@@ -157,7 +183,11 @@ export function createDeviceCenterServer({ dbPath = './data/device-center.sqlite
157
183
  return sendJson(response, 200, inventory.initialize(await readJson(request)));
158
184
  }
159
185
  if (request.method !== 'GET') return sendJson(response, 404, { error: 'not_found' });
160
- if (!cloud && path === '/api/driver/link') return sendJson(response, 200, { ...linkSummary(cloudLinkFile ? readLink(cloudLinkFile) : null), publisher: publisher?.status() ?? null });
186
+ if (!cloud && path === '/api/driver/link') return sendJson(response, 200, { ...linkSummary(cloudLinkFile ? readLink(cloudLinkFile) : null), publisher: publisher?.status() ?? null, remoteAgent: remoteAgent?.status() ?? null });
187
+ if (!cloud && path === '/api/remote/peers') {
188
+ const summary = linkSummary(cloudLinkFile ? readLink(cloudLinkFile) : null);
189
+ return sendJson(response, 200, { ...summary, peers: summary.configured && remoteClient ? (await remoteClient.peers()).peers : [] });
190
+ }
161
191
  if (path === '/api/ssh/candidates') return sendJson(response, 200, inventory.discovery());
162
192
  if (path === '/api/ssh/initialization') return sendJson(response, 200, inventory.initialization());
163
193
  if (path === '/api/health') return sendJson(response, 200, { ok: true, mode: cloud ? 'cloud' : 'local', runtime: cloud ? 'cloud' : runtime });
@@ -171,13 +201,13 @@ export function createDeviceCenterServer({ dbPath = './data/device-center.sqlite
171
201
  if (path === '/api/connector/status') return sendJson(response, 200, {
172
202
  service: { state: 'running', runtime: cloud ? 'cloud' : runtime, startedAt, persistence: process.env.DEVICE_CENTER_SERVICE_MANAGER ?? 'foreground' },
173
203
  client: { state: lastInitializeAt === null ? 'waiting' : Date.now() - lastInitializeAt > 300_000 ? 'stale' : 'observed', lastInitializeAt, evidence: 'MCP initialize request; not authenticated identity or continuous liveness' },
174
- targetAgentImplemented: false,
204
+ targetAgentImplemented: Boolean(remoteAgent),
175
205
  });
176
206
  if (path === '/api/overview') {
177
207
  if (url.searchParams.has('environment') && url.searchParams.get('environment') !== 'local') return sendJson(response, 400, { error: 'cloud_not_configured' });
178
- const devices = cloud ? registry.devices() : inventory.list();
208
+ const devices = cloud ? accountRegistry.devices() : inventory.list();
179
209
  const stats = devices.reduce((totals, device) => { totals.total++; totals[device.state]++; return totals; }, { total: 0, online: 0, unknown: 0, expired: 0 });
180
- return sendJson(response, 200, { devices, stats, projects: [], mode: cloud ? 'cloud' : 'local-inventory', enrollmentAvailable: cloud, ...(cloud ? { drivers: registry.bindings(), controlOrigin: publicOrigin } : {}) });
210
+ return sendJson(response, 200, { devices, stats, projects: [], mode: cloud ? 'cloud' : 'local-inventory', enrollmentAvailable: cloud, ...(cloud ? { drivers: accountRegistry.bindings(), controlOrigin: publicOrigin, accountMode: cloudAccess.accountMode, relayUsage: accountRegistry.relayUsage(), capabilities: { telemetry: true, relay: true, authorizedLogRead: true, remoteExecution: false, cloudMcp: false } } : {}) });
181
211
  }
182
212
  return sendJson(response, 404, { error: 'not_found' });
183
213
  } catch (cause) {
@@ -187,6 +217,7 @@ export function createDeviceCenterServer({ dbPath = './data/device-center.sqlite
187
217
  });
188
218
  server.requestTimeout = 10_000;
189
219
  if (publisher) { server.once('listening', () => publisher.start()); server.once('close', () => publisher.stop()); }
220
+ if (remoteAgent) { server.once('listening', () => remoteAgent.start()); server.once('close', () => remoteAgent.stop()); }
190
221
  return { server, db, dbPath: absoluteDbPath };
191
222
  }
192
223
 
package/server/mcp.mjs CHANGED
@@ -7,13 +7,15 @@ import { fileURLToPath } from 'node:url';
7
7
  import { currentPrincipal } from './adapters/identity/localDemo.mjs';
8
8
  import { createConnector } from './connector.mjs';
9
9
  import { createInventory } from './drivers/inventory.mjs';
10
+ import { createRemoteClient } from './drivers/remote.mjs';
11
+ import { defaultLinkFile } from './drivers/cloudLink.mjs';
10
12
  const root = resolve(fileURLToPath(new URL('..', import.meta.url)));
11
13
  const db = new DatabaseSync(resolve(process.env.DEVICE_CENTER_DB_PATH ?? resolve(root, 'data/device-center.sqlite')), { readOnly: true });
12
14
  const inventory = createInventory({ db, tenantId: currentPrincipal().tenantId, runtime: process.env.DEVICE_CENTER_RUNTIME === 'container' ? 'container' : 'native' });
13
- const handle = createConnector({ inventory, transport: 'MCP stdio' });
15
+ const handle = createConnector({ inventory, remoteClient: process.env.DEVICE_CENTER_RUNTIME === 'container' ? null : createRemoteClient({linkFile:defaultLinkFile()}), transport: 'MCP stdio' });
14
16
  let initialized = false;
15
17
  const input = readline.createInterface({ input: process.stdin, crlfDelay: Infinity, terminal: false });
16
- input.on('line', (line) => {
18
+ input.on('line', async (line) => {
17
19
  let answer;
18
20
  try {
19
21
  if (line.length > 16_384) throw new Error('too_large');
@@ -21,7 +23,7 @@ input.on('line', (line) => {
21
23
  if (message.method === 'notifications/initialized') initialized = true;
22
24
  if (Object.hasOwn(message, 'id') && !['initialize', 'ping'].includes(message.method) && !initialized) {
23
25
  answer = { jsonrpc: '2.0', id: message.id, error: { code: -32002, message: 'Initialize handshake required' } };
24
- } else answer = handle(message);
26
+ } else answer = await handle(message);
25
27
  } catch { answer = { jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }; }
26
28
  if (answer) process.stdout.write(JSON.stringify(answer) + '\n');
27
29
  });
@@ -23,8 +23,12 @@ export function createUnifiedAccess({ config, publicOrigin, now = Date.now, fetc
23
23
  const account = httpsOrigin(config.accountOrigin);
24
24
  const publicUrl = new URL(publicOrigin);
25
25
  if (publicUrl.hostname === account.hostname || !publicUrl.hostname.endsWith(`.${account.hostname}`) || !backend.hostname.endsWith(`.${account.hostname}`)) throw new Error('unified_shared_cookie_domain_required');
26
- if (typeof config.ownerEmail !== 'string' || !/^[^\s@]+@[^\s@]+$/.test(config.ownerEmail)) throw new Error('unified_owner_required');
27
- const ownerEmail = config.ownerEmail.toLowerCase();
26
+ const accountMode = config.accountMode ?? 'personal';
27
+ if (!['personal', 'multi-user'].includes(accountMode)) throw new Error('unified_account_mode_invalid');
28
+ if (accountMode === 'personal' && (typeof config.ownerEmail !== 'string' || !/^[^\s@]+@[^\s@]+$/.test(config.ownerEmail))) throw new Error('unified_owner_required');
29
+ const ownerEmail = typeof config.ownerEmail === 'string' ? config.ownerEmail.toLowerCase() : undefined;
30
+ const validId = id => typeof id === 'string' && id.length > 0 && id.length <= 256 && !/[\x00-\x1f\x7f]/.test(id);
31
+ if (config.legacyOwnerId !== undefined && (accountMode !== 'multi-user' || !validId(config.legacyOwnerId))) throw new Error('unified_legacy_owner_invalid');
28
32
  const sessions = new Map(), profiles = new Map(), attempts = new Map();
29
33
  let active = 0;
30
34
  const tokenFrom = (request) => {
@@ -59,9 +63,12 @@ export function createUnifiedAccess({ config, publicOrigin, now = Date.now, fetc
59
63
  let data;
60
64
  try { data = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { throw failure('unified_service_unavailable', 503); }
61
65
  phase = 'identity';
62
- if (!['email', 'google', 'apple'].includes(data.auth?.token_kind) || data.profile?.is_anonymous !== false || typeof data.profile.id !== 'string' || !data.profile.id || data.profile.deleted_at) throw failure('unified_sign_in_required', 401);
63
- if (data.profile.email?.toLowerCase() !== ownerEmail) throw failure('unified_owner_only', 403);
64
- const profile = { id: data.profile.id };
66
+ if (!['email', 'google', 'apple'].includes(data?.auth?.token_kind) || data?.profile?.is_anonymous !== false || !validId(data.profile.id) || data.profile.deleted_at) throw failure('unified_sign_in_required', 401);
67
+ if (accountMode === 'personal' && (typeof data.profile.email !== 'string' || data.profile.email.toLowerCase() !== ownerEmail)) throw failure('unified_owner_only', 403);
68
+ // Stable upstream subject, never an email, decoded JWT claim or browser tenant selector.
69
+ const tenantId = accountMode === 'personal' || data.profile.id === config.legacyOwnerId
70
+ ? 'cloud-owner' : `account-${digest(JSON.stringify([backend.origin, data.profile.id]))}`;
71
+ const profile = { id: data.profile.id, tenantId };
65
72
  if (profiles.size >= 64) profiles.delete(profiles.keys().next().value);
66
73
  profiles.set(key, { profile, expires: now() + PROFILE_AGE });
67
74
  return profile;
@@ -71,23 +78,32 @@ export function createUnifiedAccess({ config, publicOrigin, now = Date.now, fetc
71
78
  }
72
79
  finally { active--; }
73
80
  }
81
+ async function resolvePrincipal(request) {
82
+ expire();
83
+ const value = readCookie(request, COOKIE), token = tokenFrom(request);
84
+ if (!value || !/^[a-zA-Z0-9_-]{43}$/.test(value)) return null;
85
+ const session = sessions.get(digest(value));
86
+ if (!session) return null;
87
+ if (!token || digest(token) !== session.tokenDigest) { sessions.delete(digest(value)); return null; }
88
+ try {
89
+ const profile = await verifyProfile(token);
90
+ if (profile.id !== session.profileId) { sessions.delete(digest(value)); return null; }
91
+ return { tenantId: profile.tenantId };
92
+ } catch (cause) {
93
+ if ([401, 403].includes(cause.statusCode)) { sessions.delete(digest(value)); return null; }
94
+ throw cause; // An outage never switches to the old local password.
95
+ }
96
+ }
74
97
  return {
75
98
  provider: 'app-services',
99
+ accountMode,
100
+ legacyOwnerMapped: accountMode === 'personal' || config.legacyOwnerId !== undefined,
101
+ resolvePrincipal,
76
102
  options(request) {
77
- return { provider: 'app-services', accountLoginUrl: `${account.origin}/?login=1&loginNext=%2Fdashboard`, automaticContinue: readCookie(request, SIGNED_OUT) !== '1' };
103
+ return { provider: 'app-services', accountMode, accountLoginUrl: `${account.origin}/?login=1&loginNext=%2Fdashboard`, automaticContinue: readCookie(request, SIGNED_OUT) !== '1' };
78
104
  },
79
105
  async authenticated(request) {
80
- expire();
81
- const value = readCookie(request, COOKIE), token = tokenFrom(request);
82
- if (!value || !/^[a-zA-Z0-9_-]{43}$/.test(value)) return false;
83
- const session = sessions.get(digest(value));
84
- if (!session) return false;
85
- if (!token || digest(token) !== session.tokenDigest) { sessions.delete(digest(value)); return false; }
86
- try { return (await verifyProfile(token)).id === session.profileId; }
87
- catch (cause) {
88
- if ([401, 403].includes(cause.statusCode)) { sessions.delete(digest(value)); return false; }
89
- throw cause; // An outage never switches to the old local password.
90
- }
106
+ return Boolean(await resolvePrincipal(request));
91
107
  },
92
108
  async resume(request) {
93
109
  const token = tokenFrom(request);
package/src/api.js CHANGED
@@ -6,8 +6,11 @@ async function request(path, { timeoutMs = 12000, ...options } = {}) {
6
6
  return payload;
7
7
  }
8
8
  export const api = {
9
+ saveDeviceProfile: (profile) => request('/api/devices/profile', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(profile) }),
10
+ collectDevice: (deviceId) => request('/api/devices/collect', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ deviceId }), timeoutMs: 30000 }),
11
+ remotePeers: () => request('/api/remote/peers'),
9
12
  lookupPairing: (code) => request('/api/drivers/pairing/lookup', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ code }), timeoutMs: 12000 }),
10
- approvePairing: (id, shareSsh) => request('/api/drivers/pairing/approve', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id, shareSsh }), timeoutMs: 12000 }),
13
+ approvePairing: (id, shareSsh, code, fingerprint) => request('/api/drivers/pairing/approve', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id, shareSsh, code, fingerprint }), timeoutMs: 12000 }),
11
14
  revokeDriver: (id) => request('/api/drivers/revoke', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id }), timeoutMs: 12000 }),
12
15
  logout: () => request('/api/auth/logout', { method: 'POST' }),
13
16
  overview: () => request('/api/overview'),
@@ -7,3 +7,22 @@ export function expireSshSnapshots(overview, now = Date.now()) {
7
7
  const stats = devices.reduce((totals, device) => { totals.total++; totals[device.state]++; return totals; }, { total: 0, online: 0, unknown: 0, expired: 0 });
8
8
  return { ...overview, devices, stats };
9
9
  }
10
+
11
+ export const DEVICE_TYPE_LABELS = { unspecified: '未分类', personal: '个人电脑', server: '服务器', workstation: '工作站', other: '其他' };
12
+ export function deviceStatus(device, now = Date.now()) {
13
+ if (device.state === 'unknown') return { label: '未知', title: '尚未取得资源数据;未把设备视为已连接。' };
14
+ if (device.source === 'driver-report' && (device.managementDriver.lastSeen == null || now - device.managementDriver.lastSeen > 90_000)) return { label: '未回报', title: '超过 90 秒未收到 Driver 回报。请确认设备已唤醒、Driver 正在运行且能连接云端。旧数据保留,不表示密钥过期。' };
15
+ if (device.state === 'expired') return { label: '待采集', title: '上次资源快照较早,点击采集一次取得新数据;不表示设备断线或密钥过期。' };
16
+ return { label: '在线', title: device.transport === 'ssh' ? '最近一次 SSH 检查成功,不表示持续连接。' : '最近收到真实资源数据。' };
17
+ }
18
+ export function collectionAvailability(device, now = Date.now()) {
19
+ if (device.source === 'driver-report') {
20
+ if (device.collection?.status === 'pending' && device.collection.expiresAt > now) return { available: false, pending: true, hint: '等待设备采集…' };
21
+ if (device.kind !== 'management') return { available: false, hint: '请在管理电脑检查 SSH' };
22
+ if (device.managementDriver.lastSeen == null || now - device.managementDriver.lastSeen > 90_000) return { available: false, hint: '请启动设备上的 Driver' };
23
+ if (!device.collection?.supported) return { available: false, hint: device.collection?.reason === 'driver_control_unavailable' ? '采集通道暂不可用' : '需更新设备 Driver' };
24
+ return { available: true, hint: '通知 Driver 重新采集内存与磁盘;不执行远程命令。' };
25
+ }
26
+ if (device.transport === 'ssh') return { available: Boolean(device.ssh?.checkAvailable && !device.ssh.checking && !device.ssh.queued), hint: '使用当前 SSH 配置进行一次只读检查并采集。' };
27
+ return { available: device.source === 'local-runtime' && device.scope === 'host', hint: '从当前本机进程重新读取内存与磁盘。' };
28
+ }
package/src/login.css CHANGED
@@ -14,3 +14,7 @@ button:disabled { opacity: .6; }
14
14
  .login-link { display: grid; place-items: center; min-height: 44px; margin-top: 24px; border-radius: 8px; color: white; background: #3c622a; text-decoration: none; font-size: 14px; }
15
15
  .help { line-height: 1.7; margin: 20px 0; }
16
16
  small { color: #788473; }
17
+
18
+ .install-help { margin-top: 24px; padding-top: 18px; border-top: 1px solid #e3e7e0; line-height: 1.8; }
19
+ .install-help a { color: #3c622a; text-underline-offset: 3px; }
20
+ .install-help span { font-size: 12px; }
package/src/login.js CHANGED
@@ -30,6 +30,7 @@ try {
30
30
  const response = await fetch('/api/auth/options', { signal: AbortSignal.timeout(5000) });
31
31
  if (!response.ok) throw new Error();
32
32
  options = await response.json();
33
+ document.querySelector('#access-notes').textContent = options.accountMode === 'multi-user' ? '账号设备隔离 · HTTPS 保护' : '个人访问 · HTTPS 保护';
33
34
  loading.hidden = true;
34
35
  if (options.provider === 'app-services') {
35
36
  const target = new URL(options.accountLoginUrl);