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,84 @@
|
|
|
1
|
+
import { constants, openSync, closeSync, fstatSync, readFileSync, lstatSync, realpathSync, readSync, writeSync } from 'node:fs';
|
|
2
|
+
import { dirname, isAbsolute, resolve, join } from 'node:path';
|
|
3
|
+
import { isIP } from 'node:net';
|
|
4
|
+
import { publicIdentity, object, fail } from './driverProtocol.mjs';
|
|
5
|
+
import { validateCertificate } from './certificates.mjs';
|
|
6
|
+
import { ensurePrivateDirectory, writeLink } from './cloudLink.mjs';
|
|
7
|
+
|
|
8
|
+
export const accessFile = linkFile => join(dirname(linkFile), 'access.json');
|
|
9
|
+
export const validFingerprint = fp => typeof fp === 'string' && /^[a-f0-9]{64}$/.test(fp);
|
|
10
|
+
export function privateJson(file, fallback) {
|
|
11
|
+
let fd;
|
|
12
|
+
try {
|
|
13
|
+
fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
14
|
+
const s = fstatSync(fd);
|
|
15
|
+
if (!s.isFile() || s.nlink !== 1 || s.size > 65536 || (s.mode & 0o077) || (process.getuid && s.uid !== process.getuid())) fail('access_private_file_required');
|
|
16
|
+
return JSON.parse(readFileSync(fd, 'utf8'));
|
|
17
|
+
} catch(e) { if(e.code === 'ENOENT') return fallback; throw e; }
|
|
18
|
+
finally { if(fd !== undefined) closeSync(fd); }
|
|
19
|
+
}
|
|
20
|
+
export function readAccess(file) {
|
|
21
|
+
const value = privateJson(file, { trusts: [], grants: [], listen: null });
|
|
22
|
+
object(value, ['trusts', 'grants', 'listen']);
|
|
23
|
+
if (!Array.isArray(value.trusts) || !Array.isArray(value.grants) || value.trusts.length > 16 || value.grants.length > 16) fail('access_invalid');
|
|
24
|
+
for (const entry of [...value.trusts, ...value.grants]) {
|
|
25
|
+
if (!validFingerprint(entry.fingerprint) || !/^[a-f0-9-]{36}$/.test(entry.id)) fail('access_invalid');
|
|
26
|
+
validateCertificate(entry.certificate, entry.fingerprint, Date.now(), true);
|
|
27
|
+
}
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
export function lanEndpoint(value) {
|
|
31
|
+
object(value, ['host', 'port']);
|
|
32
|
+
const ip = isIP(value.host);
|
|
33
|
+
const parts = ip === 4 ? value.host.split('.').map(Number) : [];
|
|
34
|
+
if (!(ip === 4 && (parts[0] === 10 || parts[0] === 192 && parts[1] === 168 || parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) || ip === 6 && /^(fc|fd)/i.test(value.host)) || !Number.isInteger(value.port) || value.port < 1024 || value.port > 65535) fail('access_private_lan_endpoint_required');
|
|
35
|
+
return { host: value.host, port: value.port };
|
|
36
|
+
}
|
|
37
|
+
export function safeLogPath(path) {
|
|
38
|
+
if (typeof path !== 'string' || !isAbsolute(path) || path.length > 4096) fail('access_absolute_log_required');
|
|
39
|
+
const canonical = realpathSync(path);
|
|
40
|
+
checkLog(canonical); return canonical;
|
|
41
|
+
}
|
|
42
|
+
function checkLog(path) {
|
|
43
|
+
if (resolve(path) !== path || realpathSync(path) !== path) fail('access_log_changed');
|
|
44
|
+
for (let cursor = dirname(path); cursor !== dirname(cursor); cursor = dirname(cursor)) if(lstatSync(cursor).isSymbolicLink()) fail('access_log_changed');
|
|
45
|
+
const info = lstatSync(path);
|
|
46
|
+
if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1) fail('access_regular_log_required');
|
|
47
|
+
}
|
|
48
|
+
export function peerFingerprint(socket) {
|
|
49
|
+
const raw = socket.getPeerX509Certificate();
|
|
50
|
+
if (!raw || !socket.authorized) fail('access_peer_unauthorized', 403);
|
|
51
|
+
return publicIdentity(raw.publicKey.export({type:'spki',format:'pem'}).toString()).fingerprint;
|
|
52
|
+
}
|
|
53
|
+
export function audit(file, entry) {
|
|
54
|
+
ensurePrivateDirectory(dirname(file));
|
|
55
|
+
const fd = openSync(file, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW, 0o600);
|
|
56
|
+
try {
|
|
57
|
+
const s = fstatSync(fd);
|
|
58
|
+
if (!s.isFile() || s.nlink !== 1 || s.mode & 0o077 || s.size > 1024 ** 2 || process.getuid && s.uid !== process.getuid()) fail('access_audit_unavailable');
|
|
59
|
+
writeSync(fd, JSON.stringify({ ...entry, at: Date.now() }) + '\n');
|
|
60
|
+
} finally { closeSync(fd); }
|
|
61
|
+
}
|
|
62
|
+
export function readGrantedLog({ file, fingerprint, request, route, now = Date.now() }) {
|
|
63
|
+
object(request, ['operation', 'label', 'bytes']);
|
|
64
|
+
if (request.operation !== 'read-log' || typeof request.label !== 'string' || !/^[a-zA-Z0-9_-]{1,40}$/.test(request.label) || !Number.isInteger(request.bytes) || request.bytes < 1 || request.bytes > 65536) fail('access_invalid_operation');
|
|
65
|
+
const grant = readAccess(file).grants.find(g => g.fingerprint === fingerprint && g.label === request.label && g.expiresAt > now);
|
|
66
|
+
if (!grant) fail('access_log_not_granted', 403);
|
|
67
|
+
const auditFile = join(dirname(file), 'access-audit.jsonl');
|
|
68
|
+
audit(auditFile, { fingerprint, label: request.label, route, outcome: 'requested' });
|
|
69
|
+
let fd;
|
|
70
|
+
try {
|
|
71
|
+
checkLog(grant.path);
|
|
72
|
+
fd = openSync(grant.path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
73
|
+
const s = fstatSync(fd), current = lstatSync(grant.path);
|
|
74
|
+
if (!s.isFile() || s.nlink !== 1 || s.ino !== current.ino || s.dev !== current.dev || realpathSync(grant.path) !== grant.path) fail('access_log_changed');
|
|
75
|
+
const data = Buffer.alloc(Math.min(request.bytes, s.size));
|
|
76
|
+
const count = readSync(fd, data, 0, data.length, Math.max(0,s.size-data.length));
|
|
77
|
+
// Revocation/expiry is checked again before releasing file contents.
|
|
78
|
+
if (!readAccess(file).grants.some(g => g.fingerprint === fingerprint && g.label === request.label && g.path === grant.path && g.expiresAt > Date.now())) fail('access_log_not_granted', 403);
|
|
79
|
+
audit(auditFile, { fingerprint, label: request.label, route, bytes: count, outcome: 'allowed' });
|
|
80
|
+
return { label: request.label, bytes: count, text: data.subarray(0,count).toString('utf8'), route, observedAt: Date.now() };
|
|
81
|
+
} catch(error) { audit(auditFile, { fingerprint, label: request.label, route, outcome: 'denied' }); throw error; }
|
|
82
|
+
finally { if(fd !== undefined) closeSync(fd); }
|
|
83
|
+
}
|
|
84
|
+
export const writeAccess = (file,value) => writeLink(file,value);
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import 'reflect-metadata';
|
|
2
|
+
import { X509Certificate, createPrivateKey, createPublicKey, webcrypto, randomBytes } from 'node:crypto';
|
|
3
|
+
import { X509CertificateGenerator, BasicConstraintsExtension, KeyUsagesExtension, KeyUsageFlags, ExtendedKeyUsageExtension, SubjectAlternativeNameExtension, cryptoProvider } from '@peculiar/x509';
|
|
4
|
+
import { publicIdentity, fail } from './driverProtocol.mjs';
|
|
5
|
+
|
|
6
|
+
cryptoProvider.set(webcrypto);
|
|
7
|
+
export const certificateName = fingerprint => `dc-${fingerprint.slice(0,32)}.${fingerprint.slice(32)}.invalid`;
|
|
8
|
+
export function validateCertificate(pem, fingerprint, now = Date.now(), allowExpired = false) {
|
|
9
|
+
if (typeof pem !== 'string' || pem.length > 4096 || (pem.match(/BEGIN CERTIFICATE/g) ?? []).length !== 1) fail('driver_certificate_invalid');
|
|
10
|
+
let certificate;
|
|
11
|
+
try { certificate = new X509Certificate(pem); } catch { fail('driver_certificate_invalid'); }
|
|
12
|
+
const identity = publicIdentity(certificate.publicKey.export({ type: 'spki', format: 'pem' }).toString());
|
|
13
|
+
if (identity.fingerprint !== fingerprint || !certificate.verify(certificate.publicKey) ||
|
|
14
|
+
Date.parse(certificate.validFrom) > now || !allowExpired && Date.parse(certificate.validTo) <= now ||
|
|
15
|
+
certificate.checkHost(certificateName(fingerprint)) !== certificateName(fingerprint)) fail('driver_certificate_invalid');
|
|
16
|
+
return certificate.toString();
|
|
17
|
+
}
|
|
18
|
+
export async function issueCertificate(state) {
|
|
19
|
+
const privateDer = createPrivateKey(state.privateKey).export({ type: 'pkcs8', format: 'der' });
|
|
20
|
+
const publicDer = createPublicKey(state.publicKey).export({ type: 'spki', format: 'der' });
|
|
21
|
+
const keys = {
|
|
22
|
+
privateKey: await webcrypto.subtle.importKey('pkcs8', privateDer, 'Ed25519', false, ['sign']),
|
|
23
|
+
publicKey: await webcrypto.subtle.importKey('spki', publicDer, 'Ed25519', true, ['verify']),
|
|
24
|
+
};
|
|
25
|
+
const certificate = await X509CertificateGenerator.createSelfSigned({
|
|
26
|
+
serialNumber: randomBytes(16).toString('hex'), name: `CN=${certificateName(state.fingerprint)}`,
|
|
27
|
+
notBefore: new Date(Date.now() - 60_000), notAfter: new Date(Date.now() + 365 * 86_400_000),
|
|
28
|
+
signingAlgorithm: { name: 'Ed25519' }, keys,
|
|
29
|
+
extensions: [new BasicConstraintsExtension(true, 0, true), new KeyUsagesExtension(KeyUsageFlags.digitalSignature | KeyUsageFlags.keyCertSign, true), new ExtendedKeyUsageExtension(['1.3.6.1.5.5.7.3.1','1.3.6.1.5.5.7.3.2'], true), new SubjectAlternativeNameExtension([{ type: 'dns', value: certificateName(state.fingerprint) }])],
|
|
30
|
+
});
|
|
31
|
+
return validateCertificate(certificate.toString('pem'), state.fingerprint);
|
|
32
|
+
}
|
|
@@ -4,6 +4,7 @@ import { dirname, join } from 'node:path';
|
|
|
4
4
|
import { createHash, createPrivateKey, createPublicKey, randomBytes } from 'node:crypto';
|
|
5
5
|
import { cloudOrigin, signedRequest, publicIdentity } from './driverProtocol.mjs';
|
|
6
6
|
import { observeLocalDriver } from './local.mjs';
|
|
7
|
+
import { validateCertificate } from './certificates.mjs';
|
|
7
8
|
|
|
8
9
|
export const defaultLinkFile = (home = homedir(), platform = process.platform) => join(platform === 'darwin' ? join(home, 'Library', 'Application Support', 'Device Center') : join(home, '.local', 'share', 'device-center'), 'driver-link.json');
|
|
9
10
|
export function readLink(file) {
|
|
@@ -16,6 +17,7 @@ export function readLink(file) {
|
|
|
16
17
|
cloudOrigin(state.origin);
|
|
17
18
|
const key = createPrivateKey(state.privateKey), identity = publicIdentity(state.publicKey);
|
|
18
19
|
if (key.asymmetricKeyType !== 'ed25519' || createPublicKey(key).export({ type: 'spki', format: 'pem' }).toString() !== identity.publicKey || identity.fingerprint !== state.fingerprint || typeof state.shareSsh !== 'boolean' || typeof state.disabled !== 'boolean' || (state.driverId != null && !/^[a-f0-9-]{36}$/.test(state.driverId))) throw new Error('driver_private_file_invalid');
|
|
20
|
+
if (state.certificate) validateCertificate(state.certificate, state.fingerprint, Date.now(), true);
|
|
19
21
|
return state;
|
|
20
22
|
} catch (error) { if (error.code === 'ENOENT') return null; throw new Error('driver_private_file_invalid'); }
|
|
21
23
|
finally { if (fd !== undefined) closeSync(fd); }
|
|
@@ -33,7 +35,7 @@ export function writeLink(file, state, { create = false } = {}) {
|
|
|
33
35
|
renameSync(temp, file);
|
|
34
36
|
}
|
|
35
37
|
export function linkSummary(state) {
|
|
36
|
-
return { protocol: 'device-center-driver-v1', configured: Boolean(state?.driverId && !state.disabled), origin: state?.origin ?? null, driverId: state?.driverId ?? null, fingerprint: state?.fingerprint ?? null, shareSsh: Boolean(state?.shareSsh), disabled: Boolean(state?.disabled) };
|
|
38
|
+
return { protocol: 'device-center-driver-v1', configured: Boolean(state?.driverId && !state.disabled), origin: state?.origin ?? null, driverId: state?.driverId ?? null, fingerprint: state?.fingerprint ?? null, secureTransportReady: Boolean(state?.certificate), shareSsh: Boolean(state?.shareSsh), disabled: Boolean(state?.disabled) };
|
|
37
39
|
}
|
|
38
40
|
export async function cloudPost(origin, path, input, fetchImpl = fetch) {
|
|
39
41
|
cloudOrigin(origin);
|
|
@@ -42,7 +44,7 @@ export async function cloudPost(origin, path, input, fetchImpl = fetch) {
|
|
|
42
44
|
const response = await fetchImpl(origin + path, { method: 'POST', headers: { 'content-type': 'application/json' }, body, redirect: 'error', signal: AbortSignal.timeout(10_000) });
|
|
43
45
|
if (!/^application\/json(?:;|$)/i.test(response.headers.get('content-type') ?? '')) throw new Error('driver_cloud_unavailable');
|
|
44
46
|
const chunks = []; let size = 0;
|
|
45
|
-
for await (const chunk of response.body) { size += chunk.length; if (size >
|
|
47
|
+
for await (const chunk of response.body) { size += chunk.length; if (size > 65_536) throw new Error('driver_response_too_large'); chunks.push(chunk); }
|
|
46
48
|
const data = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
47
49
|
if (!response.ok) throw Object.assign(new Error(typeof data.error === 'string' && /^[a-z_]+$/.test(data.error) ? data.error : 'driver_cloud_unavailable'), { statusCode: response.status });
|
|
48
50
|
return data;
|
|
@@ -60,8 +62,9 @@ export function reportPayload({ inventory, shareSsh = false, observe = observeLo
|
|
|
60
62
|
})) };
|
|
61
63
|
}
|
|
62
64
|
export function createPublisher({ file, inventory, fetchImpl = fetch, read = readLink, write = writeLink, observe = observeLocalDriver }) {
|
|
63
|
-
let stopped = true, timer, busy = false, backoff = 30_000;
|
|
65
|
+
let stopped = true, timer, controlTimer, collectionTimer, controlBusy = false, controlUnsupported = false, busy = false, backoff = 30_000;
|
|
64
66
|
const status = { state: 'waiting', lastSuccess: null, error: null };
|
|
67
|
+
let pendingCollection = null;
|
|
65
68
|
async function tick() {
|
|
66
69
|
if (stopped || busy) return;
|
|
67
70
|
clearTimeout(timer);
|
|
@@ -70,7 +73,10 @@ export function createPublisher({ file, inventory, fetchImpl = fetch, read = rea
|
|
|
70
73
|
const state = read(file);
|
|
71
74
|
if (!state?.driverId || state.disabled) { status.state = state?.disabled ? 'disabled' : 'waiting'; backoff = 30_000; return; }
|
|
72
75
|
const payload = reportPayload({ inventory, shareSsh: state.shareSsh, observe });
|
|
76
|
+
const collection = pendingCollection?.expiresAt > Date.now() ? pendingCollection : null;
|
|
77
|
+
if (collection) payload.collectionRequestId = collection.id;
|
|
73
78
|
await cloudPost(state.origin, '/api/driver/report', signedRequest(state.privateKey, state.origin, '/api/driver/report', state.driverId, payload), fetchImpl);
|
|
79
|
+
if (pendingCollection === collection) pendingCollection = null;
|
|
74
80
|
Object.assign(status, { state: 'connected', lastSuccess: Date.now(), error: null }); backoff = 30_000;
|
|
75
81
|
} catch (error) {
|
|
76
82
|
Object.assign(status, { state: 'retrying', error: ['driver_revoked', 'driver_clock_skew', 'driver_scope_violation', 'driver_private_file_invalid'].includes(error.message) ? error.message : 'driver_cloud_unavailable' });
|
|
@@ -81,5 +87,41 @@ export function createPublisher({ file, inventory, fetchImpl = fetch, read = rea
|
|
|
81
87
|
}
|
|
82
88
|
} finally { busy = false; if (!stopped) timer = setTimeout(() => void tick(), backoff); }
|
|
83
89
|
}
|
|
84
|
-
|
|
90
|
+
function requestCollection(expiresAt = Date.now() + 75_000) {
|
|
91
|
+
clearTimeout(collectionTimer);
|
|
92
|
+
if (stopped || Date.now() >= expiresAt) return;
|
|
93
|
+
// Respect the existing signed telemetry rate limit, including manual collection.
|
|
94
|
+
const delay = Math.max(busy ? 250 : 0, (status.lastSuccess ?? 0) + 15_050 - Date.now());
|
|
95
|
+
collectionTimer = setTimeout(() => {
|
|
96
|
+
if (stopped || Date.now() >= expiresAt) return;
|
|
97
|
+
if (busy) requestCollection(expiresAt); else void tick();
|
|
98
|
+
}, delay);
|
|
99
|
+
}
|
|
100
|
+
async function controlTick() {
|
|
101
|
+
if (stopped || controlBusy || controlUnsupported) return;
|
|
102
|
+
controlBusy = true;
|
|
103
|
+
try {
|
|
104
|
+
const state = read(file);
|
|
105
|
+
if (!state?.driverId || state.disabled) return;
|
|
106
|
+
const path = '/api/driver/control';
|
|
107
|
+
const answer = await cloudPost(state.origin, path, signedRequest(state.privateKey, state.origin, path, state.driverId, {}), fetchImpl);
|
|
108
|
+
const command = answer.request;
|
|
109
|
+
if (command != null) {
|
|
110
|
+
// This channel has exactly one operation: collect already-authorized resource fields.
|
|
111
|
+
if (!command || Object.keys(command).some(k => !['id', 'action', 'expiresAt'].includes(k)) || !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(command.id) || command.action !== 'collect-resources' || !Number.isSafeInteger(command.expiresAt) || command.expiresAt > Date.now() + 90_000) throw new Error('driver_invalid_collection');
|
|
112
|
+
if (command.expiresAt <= Date.now()) return;
|
|
113
|
+
pendingCollection = command;
|
|
114
|
+
requestCollection(command.expiresAt);
|
|
115
|
+
}
|
|
116
|
+
} catch (error) {
|
|
117
|
+
// Old control planes keep receiving normal reports; this optional capability fails closed.
|
|
118
|
+
if (error.statusCode === 404) controlUnsupported = true;
|
|
119
|
+
} finally {
|
|
120
|
+
controlBusy = false;
|
|
121
|
+
if (!stopped && !controlUnsupported) controlTimer = setTimeout(() => void controlTick(), 5000);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return { status: () => ({ ...status }), requestCollection, controlTick,
|
|
125
|
+
start() { if (stopped) { stopped = false; void tick(); controlTimer = setTimeout(() => void controlTick(), 5000); } },
|
|
126
|
+
stop() { stopped = true; pendingCollection = null; clearTimeout(timer); clearTimeout(controlTimer); clearTimeout(collectionTimer); }, tick };
|
|
85
127
|
}
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { randomBytes, randomUUID, createHash } from 'node:crypto';
|
|
2
2
|
import { fail, object, text, publicIdentity, verifyRequest, normalizeReport } from './driverProtocol.mjs';
|
|
3
|
+
import { validateCertificate } from './certificates.mjs';
|
|
4
|
+
import { createRelay } from './relay.mjs';
|
|
5
|
+
import { createDeviceProfiles } from './deviceProfiles.mjs';
|
|
3
6
|
|
|
4
7
|
const PAIR_AGE = 5 * 60_000;
|
|
5
|
-
export function createCloudRegistry({ db, origin, now = Date.now }) {
|
|
8
|
+
export function createCloudRegistry({ db, origin, now = Date.now, multiUser = false, legacyOwnerMapped = !multiUser }) {
|
|
6
9
|
db.exec(`CREATE TABLE IF NOT EXISTS driver_pairings (
|
|
7
10
|
id TEXT PRIMARY KEY, code_hash TEXT UNIQUE NOT NULL, public_key TEXT NOT NULL, fingerprint TEXT NOT NULL,
|
|
8
11
|
name TEXT NOT NULL, platform TEXT NOT NULL, requested_ssh INTEGER NOT NULL, created_at INTEGER NOT NULL,
|
|
@@ -11,9 +14,26 @@ export function createCloudRegistry({ db, origin, now = Date.now }) {
|
|
|
11
14
|
id TEXT PRIMARY KEY, public_key TEXT NOT NULL, fingerprint TEXT NOT NULL, name TEXT NOT NULL, platform TEXT NOT NULL,
|
|
12
15
|
share_ssh INTEGER NOT NULL, created_at INTEGER NOT NULL, revoked_at INTEGER, last_seen INTEGER, report_json TEXT);
|
|
13
16
|
CREATE TABLE IF NOT EXISTS driver_nonces (scope TEXT NOT NULL, nonce TEXT NOT NULL, expires_at INTEGER NOT NULL, PRIMARY KEY(scope, nonce));`);
|
|
14
|
-
|
|
17
|
+
// Additive migration: historical personal rows keep their owner. Never transfer existing devices.
|
|
18
|
+
if (!db.prepare('PRAGMA table_info(cloud_drivers)').all().some(column => column.name === 'tenant_id')) {
|
|
19
|
+
db.exec("ALTER TABLE cloud_drivers ADD COLUMN tenant_id TEXT NOT NULL DEFAULT 'cloud-owner'");
|
|
20
|
+
}
|
|
21
|
+
db.exec('CREATE INDEX IF NOT EXISTS cloud_drivers_tenant_created ON cloud_drivers(tenant_id, created_at)');
|
|
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 });
|
|
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');
|
|
28
|
+
let starts = [], globalLookups = [], globalApprovals = [];
|
|
29
|
+
const lookups = new Map(), approvals = new Map();
|
|
15
30
|
const polls = new Map();
|
|
16
31
|
const limited = (times, max) => { const recent = times.filter(at => at > now() - 60_000); if (recent.length >= max) fail('driver_rate_limited', 429); recent.push(now()); return recent; };
|
|
32
|
+
function limitAccount(map, tenantId) {
|
|
33
|
+
for (const [id, times] of map) if (!times.some(at => at > now() - 60_000)) map.delete(id);
|
|
34
|
+
if (!map.has(tenantId) && map.size >= 256) fail('driver_rate_limited', 429);
|
|
35
|
+
map.set(tenantId, limited(map.get(tenantId) ?? [], 8));
|
|
36
|
+
}
|
|
17
37
|
const hashCode = (code) => {
|
|
18
38
|
if (typeof code !== 'string' || !/^[A-F0-9]{4}-[A-F0-9]{4}$/.test(code)) fail('invalid_pairing_code');
|
|
19
39
|
return publicCodeHash(code);
|
|
@@ -39,12 +59,26 @@ export function createCloudRegistry({ db, origin, now = Date.now }) {
|
|
|
39
59
|
if (row.revoked_at != null) fail('driver_revoked', 403);
|
|
40
60
|
return row;
|
|
41
61
|
}
|
|
42
|
-
const descriptor = row =>
|
|
43
|
-
|
|
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
|
+
};
|
|
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 }));
|
|
75
|
+
const relay = createRelay({ db, now, getDriver: id => db.prepare('SELECT * FROM cloud_drivers WHERE id=?').get(id) });
|
|
76
|
+
const registry = {
|
|
44
77
|
start(input) {
|
|
45
78
|
starts = limited(starts, 8);
|
|
46
|
-
object(input, ['publicKey', 'name', 'platform', 'shareSsh']);
|
|
79
|
+
object(input, ['publicKey', 'name', 'platform', 'shareSsh', 'certificate']);
|
|
47
80
|
const identity = publicIdentity(input.publicKey);
|
|
81
|
+
const certificate = input.certificate == null ? null : validateCertificate(input.certificate, identity.fingerprint, now());
|
|
48
82
|
const name = text(input.name), platform = text(input.platform, 64);
|
|
49
83
|
if (typeof input.shareSsh !== 'boolean') fail('invalid_driver_scope');
|
|
50
84
|
// Only protocol-expired pending requests are removed; registered devices and reports are retained.
|
|
@@ -52,7 +86,7 @@ export function createCloudRegistry({ db, origin, now = Date.now }) {
|
|
|
52
86
|
for (const [id, at] of polls) if (at <= now() - PAIR_AGE) polls.delete(id);
|
|
53
87
|
if (db.prepare('SELECT count(*) AS n FROM driver_pairings').get().n >= 32) fail('driver_pairing_busy', 429);
|
|
54
88
|
const id = randomUUID(), raw = randomBytes(4).toString('hex').toUpperCase(), code = `${raw.slice(0,4)}-${raw.slice(4)}`;
|
|
55
|
-
db.prepare('INSERT INTO driver_pairings VALUES (
|
|
89
|
+
db.prepare('INSERT INTO driver_pairings (id,code_hash,public_key,fingerprint,name,platform,requested_ssh,created_at,expires_at,certificate) VALUES (?,?,?,?,?,?,?,?,?,?)').run(id, hashCode(code), identity.publicKey, identity.fingerprint, name, platform, Number(input.shareSsh), now(), now() + PAIR_AGE, certificate);
|
|
56
90
|
return { id, code, fingerprint: identity.fingerprint, expiresAt: now() + PAIR_AGE, verificationUrl: origin, pollIntervalMs: 5000 };
|
|
57
91
|
},
|
|
58
92
|
poll(input) {
|
|
@@ -66,23 +100,31 @@ export function createCloudRegistry({ db, origin, now = Date.now }) {
|
|
|
66
100
|
if (registered.revoked_at != null) fail('driver_revoked', 403);
|
|
67
101
|
return { state: 'approved', driverId: registered.id, shareSsh: Boolean(registered.share_ssh) };
|
|
68
102
|
},
|
|
69
|
-
lookup(input) {
|
|
70
|
-
|
|
103
|
+
lookup(input, tenantId) {
|
|
104
|
+
globalLookups = limited(globalLookups, 64);
|
|
105
|
+
limitAccount(lookups, tenantId); object(input, ['code']);
|
|
71
106
|
const row = db.prepare('SELECT * FROM driver_pairings WHERE code_hash=?').get(hashCode(input.code));
|
|
72
107
|
if (!row || row.expires_at <= now() || row.driver_id) fail('driver_pairing_unavailable', 404);
|
|
73
108
|
return { id: row.id, name: row.name, platform: row.platform, fingerprint: row.fingerprint, requestedSsh: Boolean(row.requested_ssh), expiresAt: row.expires_at };
|
|
74
109
|
},
|
|
75
|
-
approve(input) {
|
|
76
|
-
|
|
110
|
+
approve(input, tenantId) {
|
|
111
|
+
globalApprovals = limited(globalApprovals, 64); limitAccount(approvals, tenantId);
|
|
112
|
+
object(input, ['id', 'code', 'fingerprint', 'shareSsh']);
|
|
77
113
|
if (typeof input.id !== 'string' || typeof input.shareSsh !== 'boolean') fail('invalid_driver_approval');
|
|
114
|
+
if (multiUser && (typeof input.code !== 'string' || typeof input.fingerprint !== 'string')) fail('driver_pairing_confirmation_required');
|
|
78
115
|
db.exec('BEGIN IMMEDIATE');
|
|
79
116
|
try {
|
|
80
117
|
const row = pairing(input.id);
|
|
81
|
-
if (row.driver_id)
|
|
118
|
+
if (row.driver_id) {
|
|
119
|
+
if (!db.prepare('SELECT id FROM cloud_drivers WHERE id=? AND tenant_id=?').get(row.driver_id, tenantId)) fail('driver_pairing_unavailable', 404);
|
|
120
|
+
fail('driver_pairing_consumed', 409);
|
|
121
|
+
}
|
|
122
|
+
if (input.code !== undefined && hashCode(input.code) !== row.code_hash) fail('driver_pairing_unavailable', 404);
|
|
123
|
+
if (input.fingerprint !== undefined && input.fingerprint !== row.fingerprint) fail('driver_pairing_confirmation_required');
|
|
82
124
|
if (input.shareSsh && !row.requested_ssh) fail('driver_scope_violation');
|
|
83
|
-
if (db.prepare('SELECT count(*) AS n FROM cloud_drivers WHERE revoked_at IS NULL').get().n >= 16) fail('driver_limit', 409);
|
|
125
|
+
if (db.prepare('SELECT count(*) AS n FROM cloud_drivers WHERE tenant_id=? AND revoked_at IS NULL').get(tenantId).n >= 16) fail('driver_limit', 409);
|
|
84
126
|
const id = randomUUID();
|
|
85
|
-
db.prepare('INSERT INTO cloud_drivers
|
|
127
|
+
db.prepare('INSERT INTO cloud_drivers (id,public_key,fingerprint,name,platform,share_ssh,created_at,tenant_id,certificate) VALUES (?,?,?,?,?,?,?,?,?)').run(id, row.public_key, row.fingerprint, row.name, row.platform, Number(input.shareSsh), now(), tenantId, row.certificate);
|
|
86
128
|
db.prepare('UPDATE driver_pairings SET driver_id=? WHERE id=? AND driver_id IS NULL').run(id, row.id);
|
|
87
129
|
db.exec('COMMIT'); return { ok: true, driverId: id };
|
|
88
130
|
} catch (error) { db.exec('ROLLBACK'); throw error; }
|
|
@@ -92,6 +134,7 @@ export function createCloudRegistry({ db, origin, now = Date.now }) {
|
|
|
92
134
|
const report = normalizeReport(input.payload, Boolean(row.share_ssh), now());
|
|
93
135
|
if (row.last_seen != null && now() - row.last_seen < 15_000) fail('driver_report_too_frequent', 429);
|
|
94
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());
|
|
95
138
|
return { ok: true, receivedAt: now() };
|
|
96
139
|
},
|
|
97
140
|
disconnect(input) {
|
|
@@ -99,25 +142,69 @@ export function createCloudRegistry({ db, origin, now = Date.now }) {
|
|
|
99
142
|
db.prepare('UPDATE cloud_drivers SET revoked_at=? WHERE id=?').run(now(), row.id);
|
|
100
143
|
return { ok: true };
|
|
101
144
|
},
|
|
102
|
-
revoke(input) {
|
|
145
|
+
revoke(input, tenantId) {
|
|
103
146
|
object(input, ['id']); if (typeof input.id !== 'string') fail('invalid_driver_id');
|
|
104
|
-
const row = db.prepare('SELECT id FROM cloud_drivers WHERE id=?').get(input.id);
|
|
147
|
+
const row = db.prepare('SELECT id FROM cloud_drivers WHERE id=? AND tenant_id=?').get(input.id, tenantId);
|
|
105
148
|
if (!row) fail('driver_unknown', 404);
|
|
106
|
-
db.prepare('UPDATE cloud_drivers SET revoked_at=COALESCE(revoked_at,?) WHERE id=?').run(now(), input.id);
|
|
149
|
+
db.prepare('UPDATE cloud_drivers SET revoked_at=COALESCE(revoked_at,?) WHERE id=? AND tenant_id=?').run(now(), input.id, tenantId);
|
|
107
150
|
return { ok: true };
|
|
108
151
|
},
|
|
109
|
-
bindings:
|
|
110
|
-
|
|
111
|
-
|
|
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
|
+
},
|
|
171
|
+
devices(tenantId) {
|
|
172
|
+
return db.prepare('SELECT * FROM cloud_drivers WHERE tenant_id=? AND revoked_at IS NULL ORDER BY created_at').all(tenantId).flatMap(row => {
|
|
112
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 } }];
|
|
113
174
|
return report.map(resource => {
|
|
114
175
|
const latest = resource.observedAt == null || row.last_seen == null ? null : Math.min(resource.observedAt, row.last_seen);
|
|
115
176
|
const age = latest == null ? null : Math.max(0, now() - latest);
|
|
116
177
|
const connected = row.last_seen != null && now() - row.last_seen <= 90_000;
|
|
117
|
-
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 } });
|
|
118
179
|
});
|
|
119
180
|
});
|
|
120
181
|
},
|
|
121
182
|
};
|
|
183
|
+
return {
|
|
184
|
+
start: registry.start, poll: registry.poll, report: registry.report, disconnect: registry.disconnect,
|
|
185
|
+
machine(action, input) {
|
|
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
|
+
}
|
|
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 }; }
|
|
195
|
+
if (action === 'peers') { object(input.payload, []); return { peers: peers(row) }; }
|
|
196
|
+
if (!row.certificate) fail('driver_certificate_required', 409);
|
|
197
|
+
return relay.handle(action, row, input.payload);
|
|
198
|
+
},
|
|
199
|
+
// Only server-verified principals reach this scope; no default global inventory API.
|
|
200
|
+
forTenant(tenantId) {
|
|
201
|
+
if (typeof tenantId !== 'string' || !/^(cloud-owner|account-[a-f0-9]{64})$/.test(tenantId)) fail('driver_account_required', 403);
|
|
202
|
+
return {
|
|
203
|
+
lookup: input => registry.lookup(input, tenantId), approve: input => registry.approve(input, tenantId),
|
|
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),
|
|
206
|
+
};
|
|
207
|
+
},
|
|
208
|
+
};
|
|
122
209
|
}
|
|
123
210
|
const publicCodeHash = code => createHash('sha256').update(code).digest('hex');
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { fail, object } from './driverProtocol.mjs';
|
|
3
|
+
|
|
4
|
+
// Opaque TLS records only. Membership, revocation and persisted quotas are checked on every exchange.
|
|
5
|
+
export function createRelay({ db, getDriver, now = Date.now, accountLimit = 100 * 1024 ** 2, globalLimit = 1024 ** 3 }) {
|
|
6
|
+
db.exec('CREATE TABLE IF NOT EXISTS relay_usage (day TEXT NOT NULL, tenant TEXT NOT NULL, bytes INTEGER NOT NULL, PRIMARY KEY(day,tenant))');
|
|
7
|
+
const channels = new Map(), lastAccept = new Map();
|
|
8
|
+
const day = () => new Date(now()).toISOString().slice(0,10);
|
|
9
|
+
function usage(tenant) {
|
|
10
|
+
const used = db.prepare('SELECT bytes FROM relay_usage WHERE day=? AND tenant=?').get(day(), tenant)?.bytes ?? 0;
|
|
11
|
+
return { day: day(), bytes: used, limitBytes: accountLimit, bytesPerSecond: 16_384, maxSessionSeconds: 60, active: [...channels.values()].filter(c => c.tenant === tenant).length };
|
|
12
|
+
}
|
|
13
|
+
function charge(tenant, bytes) {
|
|
14
|
+
if (usage(tenant).bytes + bytes > accountLimit || (db.prepare('SELECT sum(bytes) AS n FROM relay_usage WHERE day=?').get(day()).n ?? 0) + bytes > globalLimit) fail('relay_quota_exceeded', 429);
|
|
15
|
+
db.prepare('INSERT INTO relay_usage VALUES (?,?,?) ON CONFLICT(day,tenant) DO UPDATE SET bytes=bytes+excluded.bytes').run(day(), tenant, bytes);
|
|
16
|
+
}
|
|
17
|
+
function sweep() {
|
|
18
|
+
for (const [id,c] of channels) if (c.expires <= now() || [c.a,c.b].some(id => { const d = getDriver(id); return !d || d.revoked_at != null || d.tenant_id !== c.tenant; })) channels.delete(id);
|
|
19
|
+
for (const [id,at] of lastAccept) if (at < now()-60_000) lastAccept.delete(id);
|
|
20
|
+
}
|
|
21
|
+
function channel(row, id) {
|
|
22
|
+
const c = channels.get(id);
|
|
23
|
+
if (!c || c.tenant !== row.tenant_id || ![c.a,c.b].includes(row.id)) fail('relay_unavailable', 404);
|
|
24
|
+
return c;
|
|
25
|
+
}
|
|
26
|
+
return { usage, handle(action, row, payload) {
|
|
27
|
+
sweep();
|
|
28
|
+
if (action === 'relay/open') {
|
|
29
|
+
object(payload, ['targetId']);
|
|
30
|
+
const target = typeof payload.targetId === 'string' ? getDriver(payload.targetId) : null;
|
|
31
|
+
if (!target || target.id === row.id || target.tenant_id !== row.tenant_id || target.revoked_at != null || !target.certificate) fail('relay_peer_unavailable', 404);
|
|
32
|
+
if (usage(row.tenant_id).bytes >= accountLimit) fail('relay_quota_exceeded', 429);
|
|
33
|
+
if (channels.size >= 8 || [...channels.values()].filter(c => c.tenant === row.tenant_id).length >= 2 || [...channels.values()].some(c => [c.a,c.b].includes(row.id))) fail('relay_busy', 429);
|
|
34
|
+
const id = randomUUID(); channels.set(id, { a: row.id, b: target.id, tenant: row.tenant_id, expires: now()+60_000, accepted: false, qa: [], qb: [], last: {} });
|
|
35
|
+
return { channelId: id, expiresAt: now()+60_000 };
|
|
36
|
+
}
|
|
37
|
+
if (action === 'relay/accept') {
|
|
38
|
+
object(payload, []);
|
|
39
|
+
if (now()-(lastAccept.get(row.id) ?? -Infinity) < 2000) fail('relay_rate_limited', 429);
|
|
40
|
+
lastAccept.set(row.id, now());
|
|
41
|
+
const entry = [...channels].find(([,c]) => c.b === row.id && !c.accepted);
|
|
42
|
+
if (!entry) return { incoming: null };
|
|
43
|
+
const [id,c] = entry; c.accepted = true;
|
|
44
|
+
const peer = getDriver(c.a);
|
|
45
|
+
return { incoming: { channelId: id, fingerprint: peer.fingerprint, certificate: peer.certificate } };
|
|
46
|
+
}
|
|
47
|
+
if (action === 'relay/close') { object(payload, ['channelId']); channel(row, payload.channelId); channels.delete(payload.channelId); return { ok: true }; }
|
|
48
|
+
if (action !== 'relay/exchange') fail('not_found', 404);
|
|
49
|
+
object(payload, ['channelId', 'frames']);
|
|
50
|
+
const c = channel(row, payload.channelId);
|
|
51
|
+
if (now()-(c.last[row.id] ?? -Infinity) < 500) fail('relay_rate_limited', 429);
|
|
52
|
+
if (!Array.isArray(payload.frames) || payload.frames.length > 2 || payload.frames.some(f => typeof f !== 'string' || f.length > 5464 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(f) || Buffer.from(f,'base64').length > 4096)) fail('relay_invalid_frames');
|
|
53
|
+
const into = row.id === c.a ? c.qb : c.qa, out = row.id === c.a ? c.qa : c.qb;
|
|
54
|
+
if (into.length + payload.frames.length > 32) fail('relay_backpressure', 429);
|
|
55
|
+
charge(c.tenant, payload.frames.reduce((n,f) => n+Buffer.from(f,'base64').length, 0));
|
|
56
|
+
c.last[row.id] = now(); into.push(...payload.frames);
|
|
57
|
+
return { frames: out.splice(0,2), expiresAt: c.expires };
|
|
58
|
+
} };
|
|
59
|
+
}
|