device-center 0.2.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/README.md +9 -3
- package/docs/agent-install.md +105 -0
- package/docs/device-profiles.md +56 -0
- package/docs/friend-trial.md +42 -0
- package/docs/hosted-access.md +1 -1
- package/docs/npm.md +7 -7
- package/docs/onboarding.md +4 -4
- package/docs/remote-access.md +2 -2
- package/docs/roadmap.md +17 -33
- package/login.html +1 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +6 -2
- package/server/connector.mjs +1 -1
- package/server/drivers/cloudLink.mjs +42 -2
- package/server/drivers/cloudRegistry.mjs +45 -2
- package/server/drivers/deviceProfiles.mjs +47 -0
- package/server/drivers/driverProtocol.mjs +3 -2
- package/server/drivers/inventory.mjs +21 -2
- package/server/index.mjs +19 -2
- package/src/api.js +2 -0
- package/src/device-state.js +19 -0
- package/src/login.css +4 -0
- package/src/main.js +124 -26
- package/src/onboarding.js +46 -2
- package/src/start.css +47 -0
- package/src/start.js +23 -0
- package/src/styles.css +22 -0
- package/start.html +45 -0
|
@@ -2,6 +2,7 @@ import { randomBytes, randomUUID, createHash } from 'node:crypto';
|
|
|
2
2
|
import { fail, object, text, publicIdentity, verifyRequest, normalizeReport } from './driverProtocol.mjs';
|
|
3
3
|
import { validateCertificate } from './certificates.mjs';
|
|
4
4
|
import { createRelay } from './relay.mjs';
|
|
5
|
+
import { createDeviceProfiles } from './deviceProfiles.mjs';
|
|
5
6
|
|
|
6
7
|
const PAIR_AGE = 5 * 60_000;
|
|
7
8
|
export function createCloudRegistry({ db, origin, now = Date.now, multiUser = false, legacyOwnerMapped = !multiUser }) {
|
|
@@ -19,6 +20,10 @@ export function createCloudRegistry({ db, origin, now = Date.now, multiUser = fa
|
|
|
19
20
|
}
|
|
20
21
|
db.exec('CREATE INDEX IF NOT EXISTS cloud_drivers_tenant_created ON cloud_drivers(tenant_id, created_at)');
|
|
21
22
|
for (const table of ['driver_pairings', 'cloud_drivers']) if (!db.prepare(`PRAGMA table_info(${table})`).all().some(c => c.name === 'certificate')) db.exec(`ALTER TABLE ${table} ADD COLUMN certificate TEXT`);
|
|
23
|
+
if (!db.prepare('PRAGMA table_info(cloud_drivers)').all().some(c => c.name === 'control_seen_at')) db.exec('ALTER TABLE cloud_drivers ADD COLUMN control_seen_at INTEGER');
|
|
24
|
+
db.exec(`CREATE TABLE IF NOT EXISTS driver_collection_requests (driver_id TEXT PRIMARY KEY, request_id TEXT NOT NULL,
|
|
25
|
+
requested_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, completed_at INTEGER)`);
|
|
26
|
+
const profiles = createDeviceProfiles({ db, now, initialize: true });
|
|
22
27
|
if (multiUser && !legacyOwnerMapped && db.prepare("SELECT 1 FROM cloud_drivers WHERE tenant_id='cloud-owner' LIMIT 1").get()) throw new Error('cloud_legacy_owner_mapping_required');
|
|
23
28
|
let starts = [], globalLookups = [], globalApprovals = [];
|
|
24
29
|
const lookups = new Map(), approvals = new Map();
|
|
@@ -54,7 +59,18 @@ export function createCloudRegistry({ db, origin, now = Date.now, multiUser = fa
|
|
|
54
59
|
if (row.revoked_at != null) fail('driver_revoked', 403);
|
|
55
60
|
return row;
|
|
56
61
|
}
|
|
57
|
-
const descriptor = row =>
|
|
62
|
+
const descriptor = row => {
|
|
63
|
+
const profile = profiles.get(row.tenant_id, `${row.id}:host`);
|
|
64
|
+
return { id: row.id, name: profile.displayName || row.name, reportedName: row.name, profile, platform: row.platform, fingerprint: row.fingerprint, shareSsh: Boolean(row.share_ssh), createdAt: row.created_at, revokedAt: row.revoked_at, lastSeen: row.last_seen };
|
|
65
|
+
};
|
|
66
|
+
const collection = row => {
|
|
67
|
+
const request = db.prepare('SELECT * FROM driver_collection_requests WHERE driver_id=?').get(row.id);
|
|
68
|
+
const connected = row.last_seen != null && now() - row.last_seen <= 90_000;
|
|
69
|
+
const supported = connected && row.control_seen_at != null && now() - row.control_seen_at <= 30_000;
|
|
70
|
+
return { supported, reason: !connected ? 'driver_not_connected' : row.control_seen_at == null ? 'driver_upgrade_required' : !supported ? 'driver_control_unavailable' : null,
|
|
71
|
+
status: !request ? 'idle' : request.completed_at != null ? 'completed' : request.expires_at <= now() ? 'timeout' : 'pending',
|
|
72
|
+
requestedAt: request?.requested_at ?? null, completedAt: request?.completed_at ?? null, expiresAt: request?.expires_at ?? null };
|
|
73
|
+
};
|
|
58
74
|
const peers = row => db.prepare('SELECT * FROM cloud_drivers WHERE tenant_id=? AND revoked_at IS NULL AND certificate IS NOT NULL').all(row.tenant_id).map(peer => ({ ...descriptor(peer), certificate: peer.certificate }));
|
|
59
75
|
const relay = createRelay({ db, now, getDriver: id => db.prepare('SELECT * FROM cloud_drivers WHERE id=?').get(id) });
|
|
60
76
|
const registry = {
|
|
@@ -118,6 +134,7 @@ export function createCloudRegistry({ db, origin, now = Date.now, multiUser = fa
|
|
|
118
134
|
const report = normalizeReport(input.payload, Boolean(row.share_ssh), now());
|
|
119
135
|
if (row.last_seen != null && now() - row.last_seen < 15_000) fail('driver_report_too_frequent', 429);
|
|
120
136
|
db.prepare('UPDATE cloud_drivers SET last_seen=?, report_json=? WHERE id=?').run(now(), JSON.stringify(report), row.id);
|
|
137
|
+
if (report.collectionRequestId && report.resources[0].observedAt != null) db.prepare('UPDATE driver_collection_requests SET completed_at=? WHERE driver_id=? AND request_id=? AND completed_at IS NULL AND expires_at>?').run(now(), row.id, report.collectionRequestId, now());
|
|
121
138
|
return { ok: true, receivedAt: now() };
|
|
122
139
|
},
|
|
123
140
|
disconnect(input) {
|
|
@@ -133,6 +150,24 @@ export function createCloudRegistry({ db, origin, now = Date.now, multiUser = fa
|
|
|
133
150
|
return { ok: true };
|
|
134
151
|
},
|
|
135
152
|
bindings: tenantId => db.prepare('SELECT * FROM cloud_drivers WHERE tenant_id=? ORDER BY created_at').all(tenantId).map(descriptor),
|
|
153
|
+
saveProfile(input, tenantId) {
|
|
154
|
+
if (!registry.devices(tenantId).some(d => d.id === input?.deviceId)) fail('device_not_found', 404);
|
|
155
|
+
return { profile: profiles.save(tenantId, input) };
|
|
156
|
+
},
|
|
157
|
+
collect(input, tenantId) {
|
|
158
|
+
object(input, ['deviceId']);
|
|
159
|
+
if (typeof input.deviceId !== 'string') fail('invalid_device_id');
|
|
160
|
+
const device = registry.devices(tenantId).find(d => d.id === input.deviceId);
|
|
161
|
+
if (!device) fail('device_not_found', 404);
|
|
162
|
+
if (device.kind !== 'management') fail('collection_requires_local_ssh_check', 409);
|
|
163
|
+
const row = db.prepare('SELECT * FROM cloud_drivers WHERE id=? AND tenant_id=? AND revoked_at IS NULL').get(device.managementDriver.id, tenantId);
|
|
164
|
+
const current = collection(row);
|
|
165
|
+
if (!current.supported) fail(current.reason, 409);
|
|
166
|
+
if (current.status === 'pending') return { status: 'pending', collection: current };
|
|
167
|
+
if (current.requestedAt != null && now() - current.requestedAt < 10_000) fail('collection_rate_limited', 429);
|
|
168
|
+
db.prepare('INSERT INTO driver_collection_requests VALUES (?,?,?,?,NULL) ON CONFLICT(driver_id) DO UPDATE SET request_id=excluded.request_id,requested_at=excluded.requested_at,expires_at=excluded.expires_at,completed_at=NULL').run(row.id, randomUUID(), now(), now() + 75_000);
|
|
169
|
+
return { status: 'pending', collection: collection(row) };
|
|
170
|
+
},
|
|
136
171
|
devices(tenantId) {
|
|
137
172
|
return db.prepare('SELECT * FROM cloud_drivers WHERE tenant_id=? AND revoked_at IS NULL ORDER BY created_at').all(tenantId).flatMap(row => {
|
|
138
173
|
const report = row.report_json ? JSON.parse(row.report_json).resources : [{ id: 'host', name: row.name, platform: row.platform, kind: 'management', observedAt: null, memory: { status: 'unobserved', usedMb: null, totalMb: null }, disk: { status: 'unobserved', usedGb: null, totalGb: null } }];
|
|
@@ -140,7 +175,7 @@ export function createCloudRegistry({ db, origin, now = Date.now, multiUser = fa
|
|
|
140
175
|
const latest = resource.observedAt == null || row.last_seen == null ? null : Math.min(resource.observedAt, row.last_seen);
|
|
141
176
|
const age = latest == null ? null : Math.max(0, now() - latest);
|
|
142
177
|
const connected = row.last_seen != null && now() - row.last_seen <= 90_000;
|
|
143
|
-
return { ...resource, id: `${row.id}:${resource.id}`, role: resource.kind === 'management' ? 'driver' : 'resource', source: 'driver-report', transport: 'driver', scope: 'host', state: age == null ? 'unknown' : !connected || age > (resource.kind === 'management' ? 90_000 : 300_000) ? 'expired' : 'online', lastHeartbeat: latest, heartbeatAgeMs: age, memory: resource.memory, disk: resource.disk, synthetic: false, managementDriver: { ...descriptor(row), origin, connected }, capabilities: { observation: true, remoteExecution: false } };
|
|
178
|
+
return profiles.apply(tenantId, { ...resource, id: `${row.id}:${resource.id}`, role: resource.kind === 'management' ? 'driver' : 'resource', source: 'driver-report', transport: 'driver', scope: 'host', state: age == null ? 'unknown' : !connected || age > (resource.kind === 'management' ? 90_000 : 300_000) ? 'expired' : 'online', lastHeartbeat: latest, heartbeatAgeMs: age, memory: resource.memory, disk: resource.disk, synthetic: false, managementDriver: { ...descriptor(row), origin, connected }, collection: resource.kind === 'management' ? collection(row) : { supported: false, reason: 'collection_requires_local_ssh_check', status: 'idle' }, capabilities: { observation: true, remoteExecution: false } });
|
|
144
179
|
});
|
|
145
180
|
});
|
|
146
181
|
},
|
|
@@ -149,6 +184,13 @@ export function createCloudRegistry({ db, origin, now = Date.now, multiUser = fa
|
|
|
149
184
|
start: registry.start, poll: registry.poll, report: registry.report, disconnect: registry.disconnect,
|
|
150
185
|
machine(action, input) {
|
|
151
186
|
const row = driver(input, `/api/driver/${action}`);
|
|
187
|
+
if (action === 'control') {
|
|
188
|
+
object(input.payload, []);
|
|
189
|
+
if (row.control_seen_at != null && now() - row.control_seen_at < 3000) fail('driver_control_too_frequent', 429);
|
|
190
|
+
db.prepare('UPDATE cloud_drivers SET control_seen_at=? WHERE id=?').run(now(), row.id);
|
|
191
|
+
const request = db.prepare('SELECT * FROM driver_collection_requests WHERE driver_id=? AND completed_at IS NULL AND expires_at>?').get(row.id, now());
|
|
192
|
+
return { request: request ? { id: request.request_id, action: 'collect-resources', expiresAt: request.expires_at } : null };
|
|
193
|
+
}
|
|
152
194
|
if (action === 'certificate') { object(input.payload, ['certificate']); const cert = validateCertificate(input.payload.certificate, row.fingerprint, now()); db.prepare('UPDATE cloud_drivers SET certificate=? WHERE id=?').run(cert, row.id); return { ok: true }; }
|
|
153
195
|
if (action === 'peers') { object(input.payload, []); return { peers: peers(row) }; }
|
|
154
196
|
if (!row.certificate) fail('driver_certificate_required', 409);
|
|
@@ -160,6 +202,7 @@ export function createCloudRegistry({ db, origin, now = Date.now, multiUser = fa
|
|
|
160
202
|
return {
|
|
161
203
|
lookup: input => registry.lookup(input, tenantId), approve: input => registry.approve(input, tenantId),
|
|
162
204
|
revoke: input => registry.revoke(input, tenantId), bindings: () => registry.bindings(tenantId), devices: () => registry.devices(tenantId), relayUsage: () => relay.usage(tenantId),
|
|
205
|
+
saveProfile: input => registry.saveProfile(input, tenantId), collect: input => registry.collect(input, tenantId),
|
|
163
206
|
};
|
|
164
207
|
},
|
|
165
208
|
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Descriptive owner metadata. It never changes device identity or grants access.
|
|
2
|
+
export const DEVICE_TYPES = ['unspecified', 'personal', 'server', 'workstation', 'other'];
|
|
3
|
+
const fail = (message, statusCode = 400) => { throw Object.assign(new Error(message), { statusCode }); };
|
|
4
|
+
function string(value, max, multiline = false) {
|
|
5
|
+
if (typeof value !== 'string' || value.length > max || (multiline ? /[\x00-\x09\x0b-\x1f\x7f]/ : /[\x00-\x1f\x7f]/).test(value)) fail('invalid_device_profile');
|
|
6
|
+
return value.trim();
|
|
7
|
+
}
|
|
8
|
+
export function normalizeDeviceProfile(value) {
|
|
9
|
+
if (!value || Array.isArray(value) || Object.keys(value).some(k => !['displayName', 'type', 'purpose', 'tags', 'services'].includes(k)) || !DEVICE_TYPES.includes(value.type)) fail('invalid_device_profile');
|
|
10
|
+
const list = (items, max, length) => {
|
|
11
|
+
if (!Array.isArray(items) || items.length > max) fail('invalid_device_profile');
|
|
12
|
+
const result = items.map(v => string(v, length));
|
|
13
|
+
if (result.some(v => !v)) fail('invalid_device_profile');
|
|
14
|
+
return [...new Set(result)];
|
|
15
|
+
};
|
|
16
|
+
return { displayName: string(value.displayName, 80), type: value.type, purpose: string(value.purpose, 240, true), tags: list(value.tags, 8, 24), services: list(value.services, 12, 40) };
|
|
17
|
+
}
|
|
18
|
+
const EMPTY = { displayName: '', type: 'unspecified', purpose: '', tags: [], services: [], revision: 0, updatedAt: null, servicesSource: 'user-declared' };
|
|
19
|
+
export function createDeviceProfiles({ db, now = Date.now, initialize = false }) {
|
|
20
|
+
const ensure = () => db.exec(`CREATE TABLE IF NOT EXISTS device_profiles (
|
|
21
|
+
tenant_id TEXT NOT NULL, device_id TEXT NOT NULL, profile_json TEXT NOT NULL,
|
|
22
|
+
revision INTEGER NOT NULL, updated_at INTEGER NOT NULL, PRIMARY KEY(tenant_id, device_id))`);
|
|
23
|
+
if (initialize) ensure();
|
|
24
|
+
const get = (tenantId, id) => {
|
|
25
|
+
if (!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='device_profiles'").get()) return { ...EMPTY, tags: [], services: [] };
|
|
26
|
+
const row = db.prepare('SELECT * FROM device_profiles WHERE tenant_id=? AND device_id=?').get(tenantId, id);
|
|
27
|
+
return row ? { ...normalizeDeviceProfile(JSON.parse(row.profile_json)), revision: row.revision, updatedAt: row.updated_at, servicesSource: 'user-declared' } : { ...EMPTY, tags: [], services: [] };
|
|
28
|
+
};
|
|
29
|
+
return {
|
|
30
|
+
get,
|
|
31
|
+
apply(tenantId, device) {
|
|
32
|
+
const profile = get(tenantId, device.id);
|
|
33
|
+
return { ...device, reportedName: device.reportedName ?? device.name, name: profile.displayName || device.name, profile };
|
|
34
|
+
},
|
|
35
|
+
save(tenantId, input) {
|
|
36
|
+
if (!input || Array.isArray(input) || Object.keys(input).some(k => !['deviceId', 'profile', 'revision'].includes(k)) || typeof input.deviceId !== 'string' || !input.deviceId || input.deviceId.length > 200 || !Number.isSafeInteger(input.revision) || input.revision < 0) fail('invalid_device_profile');
|
|
37
|
+
const profile = normalizeDeviceProfile(input.profile); ensure();
|
|
38
|
+
db.exec('BEGIN IMMEDIATE');
|
|
39
|
+
try {
|
|
40
|
+
if (get(tenantId, input.deviceId).revision !== input.revision) fail('device_profile_conflict', 409);
|
|
41
|
+
db.prepare('INSERT INTO device_profiles VALUES (?,?,?,?,?) ON CONFLICT(tenant_id,device_id) DO UPDATE SET profile_json=excluded.profile_json,revision=excluded.revision,updated_at=excluded.updated_at').run(tenantId, input.deviceId, JSON.stringify(profile), input.revision + 1, now());
|
|
42
|
+
db.exec('COMMIT');
|
|
43
|
+
return get(tenantId, input.deviceId);
|
|
44
|
+
} catch (error) { db.exec('ROLLBACK'); throw error; }
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -50,7 +50,8 @@ function metric(input, kind) {
|
|
|
50
50
|
return { status: input.status, [used]: input.status === 'ok' ? input[used] : null, [total]: input.status === 'ok' ? input[total] : null };
|
|
51
51
|
}
|
|
52
52
|
export function normalizeReport(payload, shareSsh, now) {
|
|
53
|
-
object(payload, ['resources']);
|
|
53
|
+
object(payload, ['resources', 'collectionRequestId']);
|
|
54
|
+
if (payload.collectionRequestId !== undefined && (typeof payload.collectionRequestId !== 'string' || !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(payload.collectionRequestId))) fail('invalid_collection_request');
|
|
54
55
|
if (!Array.isArray(payload.resources) || !payload.resources.length || payload.resources.length > 16) fail('invalid_driver_resources');
|
|
55
56
|
const ids = new Set();
|
|
56
57
|
const resources = payload.resources.map((value, index) => {
|
|
@@ -62,5 +63,5 @@ export function normalizeReport(payload, shareSsh, now) {
|
|
|
62
63
|
if (observedAt !== null && (!Number.isSafeInteger(observedAt) || observedAt < 0 || observedAt > now + 90_000)) fail('invalid_driver_observation_time');
|
|
63
64
|
return { id: value.id, name: text(value.name), platform: text(value.platform, 64), kind: value.kind, observedAt: observedAt == null ? null : Math.min(observedAt, now), memory: metric(value.memory, 'memory'), disk: metric(value.disk, 'disk') };
|
|
64
65
|
});
|
|
65
|
-
return { resources };
|
|
66
|
+
return { resources, ...(payload.collectionRequestId ? { collectionRequestId: payload.collectionRequestId } : {}) };
|
|
66
67
|
}
|
|
@@ -4,12 +4,14 @@ import { discoverSsh } from './sshConfig.mjs';
|
|
|
4
4
|
import { prepareSshProfile, probeSsh } from './sshProbe.mjs';
|
|
5
5
|
import { normalizeConnectionPlan, readConnectionPlan, writeConnectionPlan } from './connectionPlans.mjs';
|
|
6
6
|
import { inspectSshImport } from './sshInitialization.mjs';
|
|
7
|
+
import { createDeviceProfiles } from './deviceProfiles.mjs';
|
|
7
8
|
|
|
8
9
|
function associations(db, tenantId) {
|
|
9
10
|
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='ssh_associations'").get()) return [];
|
|
10
11
|
return db.prepare('SELECT alias, created_at AS createdAt FROM ssh_associations WHERE tenant_id = ? ORDER BY alias COLLATE NOCASE').all(tenantId);
|
|
11
12
|
}
|
|
12
13
|
export function createInventory({ db, tenantId, runtime = 'native', sshHome, sshProbe = probeSsh }) {
|
|
14
|
+
const profiles = createDeviceProfiles({ db });
|
|
13
15
|
const activeChecks = new Set();
|
|
14
16
|
const pendingChecks = new Set();
|
|
15
17
|
const exclusions = () => db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='ssh_exclusions'").get()
|
|
@@ -48,7 +50,7 @@ export function createInventory({ db, tenantId, runtime = 'native', sshHome, ssh
|
|
|
48
50
|
capabilities: { observation: Boolean(profile), remoteExecution: false },
|
|
49
51
|
};
|
|
50
52
|
});
|
|
51
|
-
return [observeLocalDriver({ runtime }), ...remote, ...listDevices(db, tenantId)];
|
|
53
|
+
return [observeLocalDriver({ runtime }), ...remote, ...listDevices(db, tenantId)].map(device => profiles.apply(tenantId, device));
|
|
52
54
|
};
|
|
53
55
|
const check = async (input) => {
|
|
54
56
|
if (!input || Array.isArray(input) || Object.keys(input).some((key) => key !== 'alias') || typeof input.alias !== 'string') throw Object.assign(new Error('invalid_ssh_check'), { statusCode: 400 });
|
|
@@ -149,5 +151,22 @@ export function createInventory({ db, tenantId, runtime = 'native', sshHome, ssh
|
|
|
149
151
|
return { removed, excluded: chosen.length, systemConfigurationChanged: false };
|
|
150
152
|
} catch (cause) { db.exec('ROLLBACK'); throw cause; }
|
|
151
153
|
};
|
|
152
|
-
|
|
154
|
+
const find = input => {
|
|
155
|
+
if (!input || Array.isArray(input) || typeof input.deviceId !== 'string') throw Object.assign(new Error('invalid_device_id'), { statusCode: 400 });
|
|
156
|
+
const device = list().find(d => d.id === input.deviceId);
|
|
157
|
+
if (!device) throw Object.assign(new Error('device_not_found'), { statusCode: 404 });
|
|
158
|
+
return device;
|
|
159
|
+
};
|
|
160
|
+
const saveProfile = input => { find(input); return { profile: profiles.save(tenantId, input) }; };
|
|
161
|
+
const collect = async input => {
|
|
162
|
+
if (Object.keys(input ?? {}).some(k => k !== 'deviceId')) throw Object.assign(new Error('invalid_collection_request'), { statusCode: 400 });
|
|
163
|
+
const device = find(input);
|
|
164
|
+
if (device.transport === 'ssh') {
|
|
165
|
+
const result = await check({ alias: device.ssh.alias });
|
|
166
|
+
if (!result.ok) return { status: 'failed', error: result.error, device: list().find(d => d.id === device.id) };
|
|
167
|
+
}
|
|
168
|
+
else if (device.source !== 'local-runtime' || runtime !== 'native') throw Object.assign(new Error('collection_unavailable'), { statusCode: 409 });
|
|
169
|
+
return { status: 'completed', device: list().find(d => d.id === device.id) };
|
|
170
|
+
};
|
|
171
|
+
return { list, discovery, associate, check, checkBatch, remove, saveProfile, collect, associateAndCheck: (input) => configure(input, false), initializeAndCheck: (input) => configure(input, true), saveConnections, initialization, initialize };
|
|
153
172
|
}
|
package/server/index.mjs
CHANGED
|
@@ -13,6 +13,7 @@ import { createRemoteAgent, createRemoteClient } from './drivers/remote.mjs';
|
|
|
13
13
|
|
|
14
14
|
const PROJECT_ROOT = resolve(fileURLToPath(new URL('..', import.meta.url)));
|
|
15
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' });
|
|
16
17
|
function sendJson(response, status, payload, headers = {}) {
|
|
17
18
|
response.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff', ...headers });
|
|
18
19
|
response.end(JSON.stringify(payload));
|
|
@@ -42,6 +43,7 @@ async function serveStatic(pathname, response) {
|
|
|
42
43
|
let filePath;
|
|
43
44
|
if (pathname === '/' || pathname === '/index.html') filePath = resolve(PROJECT_ROOT, 'index.html');
|
|
44
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');
|
|
45
47
|
else if (pathname.startsWith('/src/')) {
|
|
46
48
|
filePath = resolve(PROJECT_ROOT, `.${pathname}`);
|
|
47
49
|
if (!filePath.startsWith(resolve(PROJECT_ROOT, 'src') + '/')) return sendJson(response, 404, { error: 'not_found' });
|
|
@@ -79,11 +81,18 @@ export function createDeviceCenterServer({ dbPath = './data/device-center.sqlite
|
|
|
79
81
|
const origin = cloudAccess ? cloudAccess.validate(request) : validateLocalRequest(request);
|
|
80
82
|
const url = new URL(request.url ?? '/', origin);
|
|
81
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
|
+
}
|
|
82
91
|
let accountRegistry;
|
|
83
92
|
if (cloud) {
|
|
84
93
|
const machineActions = { '/api/driver/pair/start': 'start', '/api/driver/pair/poll': 'poll', '/api/driver/report': 'report', '/api/driver/disconnect': 'disconnect' };
|
|
85
94
|
if (Object.hasOwn(machineActions, path) && request.method === 'POST') return sendJson(response, 200, registry[machineActions[path]](await readJson(request)));
|
|
86
|
-
if (['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)));
|
|
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)));
|
|
87
96
|
if (['/login', '/src/login.js', '/src/login.css'].includes(path) && request.method === 'GET') return await serveStatic(path, response);
|
|
88
97
|
if (path === '/api/auth/options' && request.method === 'GET') return sendJson(response, 200, cloudAccess.options(request));
|
|
89
98
|
if (path === '/api/auth/continue' && request.method === 'POST') {
|
|
@@ -111,7 +120,7 @@ export function createDeviceCenterServer({ dbPath = './data/device-center.sqlite
|
|
|
111
120
|
}
|
|
112
121
|
accountRegistry = registry.forTenant(principal.tenantId);
|
|
113
122
|
if (path === '/api/drivers' && request.method === 'GET') return sendJson(response, 200, { drivers: accountRegistry.bindings() });
|
|
114
|
-
const ownerActions = { '/api/drivers/pairing/lookup': 'lookup', '/api/drivers/pairing/approve': 'approve', '/api/drivers/revoke': 'revoke' };
|
|
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' };
|
|
115
124
|
if (Object.hasOwn(ownerActions, path) && request.method === 'POST') {
|
|
116
125
|
if (request.headers.origin !== origin) return sendJson(response, 403, { error: 'same_origin_required' });
|
|
117
126
|
return sendJson(response, 200, accountRegistry[ownerActions[path]](await readJson(request)));
|
|
@@ -144,6 +153,14 @@ export function createDeviceCenterServer({ dbPath = './data/device-center.sqlite
|
|
|
144
153
|
if (request.method !== 'GET' || !web) return sendJson(response, 404, { error: 'not_found' });
|
|
145
154
|
return await serveStatic(path, response);
|
|
146
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
|
+
}
|
|
147
164
|
if (path === '/api/devices/ssh' && request.method === 'POST') {
|
|
148
165
|
if (request.headers.origin !== origin) return sendJson(response, 403, { error: 'same_origin_required' });
|
|
149
166
|
return sendJson(response, 200, inventory.associate(await readJson(request)));
|
package/src/api.js
CHANGED
|
@@ -6,6 +6,8 @@ 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 }),
|
|
9
11
|
remotePeers: () => request('/api/remote/peers'),
|
|
10
12
|
lookupPairing: (code) => request('/api/drivers/pairing/lookup', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ code }), timeoutMs: 12000 }),
|
|
11
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 }),
|
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; }
|