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.
- package/CONTRIBUTING.md +1 -1
- package/Dockerfile +2 -2
- package/README.md +37 -127
- package/SECURITY.md +7 -15
- package/bin/device-center.mjs +6 -3
- package/docs/agent-install.md +105 -0
- package/docs/architecture.md +25 -57
- package/docs/connection-routing.md +1 -0
- package/docs/device-profiles.md +56 -0
- package/docs/driver-binding.md +6 -1
- package/docs/friend-trial.md +42 -0
- package/docs/hosted-access.md +52 -0
- package/docs/npm.md +19 -35
- package/docs/onboarding.md +8 -5
- package/docs/remote-access.md +76 -0
- package/docs/roadmap.md +17 -28
- package/docs/security-and-sync.md +9 -64
- package/login.html +1 -1
- package/npm-shrinkwrap.json +304 -0
- package/package.json +22 -4
- package/scripts/access.mjs +48 -0
- package/scripts/driver.mjs +4 -3
- package/scripts/release-check.mjs +4 -0
- package/server/cloudAccess.mjs +13 -6
- package/server/connector.mjs +11 -5
- package/server/drivers/access.mjs +84 -0
- package/server/drivers/certificates.mjs +32 -0
- package/server/drivers/cloudLink.mjs +46 -4
- package/server/drivers/cloudRegistry.mjs +107 -20
- package/server/drivers/deviceProfiles.mjs +47 -0
- package/server/drivers/driverProtocol.mjs +3 -2
- package/server/drivers/inventory.mjs +21 -2
- package/server/drivers/relay.mjs +59 -0
- package/server/drivers/remote.mjs +111 -0
- package/server/index.mjs +44 -13
- package/server/mcp.mjs +5 -3
- package/server/unifiedAccess.mjs +33 -17
- package/src/api.js +4 -1
- package/src/device-state.js +19 -0
- package/src/login.css +4 -0
- package/src/login.js +1 -0
- package/src/main.js +197 -42
- package/src/onboarding.js +62 -0
- package/src/start.css +47 -0
- package/src/start.js +23 -0
- package/src/styles.css +22 -0
- 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
|
-
|
|
69
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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,
|
|
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:
|
|
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 ?
|
|
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:
|
|
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
|
});
|
package/server/unifiedAccess.mjs
CHANGED
|
@@ -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
|
-
|
|
27
|
-
|
|
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
|
|
63
|
-
if (data.profile.email
|
|
64
|
-
|
|
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
|
-
|
|
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'),
|
package/src/device-state.js
CHANGED
|
@@ -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);
|