@volter/teams 0.1.6 → 0.1.8
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/home.mjs +8 -2
- package/machine-core.mjs +20 -9
- package/package.json +1 -1
- package/server/http.mjs +7 -6
package/home.mjs
CHANGED
|
@@ -31,14 +31,19 @@ export function teamsHome(env = process.env) {
|
|
|
31
31
|
return dir;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/** A Windows named pipe (`\\.\pipe\…`): no file to make, chmod or remove; it vanishes with its last handle. */
|
|
35
|
+
export const isNamedPipe = (path) => /^\\\\[.?]\\pipe\\/i.test(path);
|
|
36
|
+
|
|
34
37
|
/**
|
|
35
38
|
* A Unix socket path for `name` in `dir`: the path itself when it fits a socket
|
|
36
39
|
* address (104 bytes on macOS, 108 on Linux); otherwise, as tmux does, a
|
|
37
40
|
* per-user directory under /tmp that only this user may enter, with the socket
|
|
38
|
-
* named by a hash of the path it stands for.
|
|
41
|
+
* named by a hash of the path it stands for. Node on Windows listens only on
|
|
42
|
+
* named pipes, so there it is always a pipe named by that same hash.
|
|
39
43
|
*/
|
|
40
|
-
export function shortSocketPath(dir, name) {
|
|
44
|
+
export function shortSocketPath(dir, name, platform = process.platform) {
|
|
41
45
|
const inside = join(dir, name);
|
|
46
|
+
if (platform === 'win32') return `\\\\.\\pipe\\supercode-${createHash('sha256').update(inside).digest('hex').slice(0, 24)}`;
|
|
42
47
|
if (Buffer.byteLength(inside) <= 100) return inside;
|
|
43
48
|
const uid = typeof process.getuid === 'function' ? process.getuid() : 'user';
|
|
44
49
|
return join('/tmp', `supercode-machine-${uid}`, `${createHash('sha256').update(inside).digest('hex').slice(0, 24)}.sock`);
|
|
@@ -49,6 +54,7 @@ export const machineSocketPath = (home) => shortSocketPath(home, 'machine.sock')
|
|
|
49
54
|
|
|
50
55
|
/** The socket's directory exists; the shared /tmp fallback must be ours and admit only us, and anything else there is refused, never adopted. */
|
|
51
56
|
export function ensureSocketDirectory(socketPath) {
|
|
57
|
+
if (isNamedPipe(socketPath)) return;
|
|
52
58
|
const dir = dirname(socketPath);
|
|
53
59
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
54
60
|
if (!basename(dir).startsWith('supercode-machine-') || dirname(dir) !== '/tmp') return;
|
package/machine-core.mjs
CHANGED
|
@@ -15,7 +15,7 @@ import { Channel, NODE_PROTOCOL } from './channel.mjs';
|
|
|
15
15
|
import { holds, admitFromGrants, LOCAL_OPERATOR } from './admission.mjs';
|
|
16
16
|
import { findProduct, DAEMON_KINDS, PRODUCT_MAX_MESSAGE, productProof, sameSecret } from './products.mjs';
|
|
17
17
|
import { loadOrCreateKeyPair, publicKeyId } from './identity.mjs';
|
|
18
|
-
import { teamsHome, paths, writeJson, readLimits, AccountLog, ensureSocketDirectory } from './home.mjs';
|
|
18
|
+
import { teamsHome, paths, writeJson, readLimits, AccountLog, ensureSocketDirectory, isNamedPipe } from './home.mjs';
|
|
19
19
|
|
|
20
20
|
export const MACHINE_VERSION = 2;
|
|
21
21
|
const LINK_BACKOFF_MIN_MS = 1_000;
|
|
@@ -93,8 +93,9 @@ export class MachineCore extends EventEmitter {
|
|
|
93
93
|
this.server.once('error', reject);
|
|
94
94
|
this.server.listen(this.socketPath, () => { this.server.off('error', reject); resolveListen(); });
|
|
95
95
|
});
|
|
96
|
-
// Owner-only: the socket is the local door, and whoever opens it is this OS user.
|
|
97
|
-
|
|
96
|
+
// Owner-only: the socket is the local door, and whoever opens it is this OS user. A named pipe's default
|
|
97
|
+
// access already admits only its owner (and SYSTEM and Administrators) to connect.
|
|
98
|
+
if (!isNamedPipe(this.socketPath)) chmodSync(this.socketPath, 0o600);
|
|
98
99
|
this.address = this.socketPath;
|
|
99
100
|
writeJson(this.p.machine, { pid: process.pid, id: this.id, socket: this.socketPath, startedAt: Date.now(), version: MACHINE_VERSION, protocol: NODE_PROTOCOL, floor: this.floor, server: this.link?.server ?? null });
|
|
100
101
|
this.emit('listening', this.socketPath);
|
|
@@ -115,7 +116,7 @@ export class MachineCore extends EventEmitter {
|
|
|
115
116
|
for (const { channel } of this.connections.values()) channel.close();
|
|
116
117
|
this.server?.close();
|
|
117
118
|
await this.afterStop();
|
|
118
|
-
rmSync(this.socketPath, { force: true });
|
|
119
|
+
if (!isNamedPipe(this.socketPath)) rmSync(this.socketPath, { force: true });
|
|
119
120
|
if (existsSync(this.p.machine)) rmSync(this.p.machine);
|
|
120
121
|
}
|
|
121
122
|
|
|
@@ -137,7 +138,7 @@ export class MachineCore extends EventEmitter {
|
|
|
137
138
|
* the daemon runs. The server opens a stream on it per stream a caller opens.
|
|
138
139
|
*/
|
|
139
140
|
#connectLink() {
|
|
140
|
-
if (this.stopping || !this.link) return;
|
|
141
|
+
if (this.stopping || !this.link || this.linkReplaced) return;
|
|
141
142
|
const { server, teamId, machineId, credential, incarnationId } = this.link;
|
|
142
143
|
const url = `${server.replace(/^http/, 'ws')}/api/v1/teams/${encodeURIComponent(teamId)}/machines/${encodeURIComponent(machineId)}/link`;
|
|
143
144
|
const socket = new WebSocket(url, LINK_PROTOCOL, { headers: { authorization: `Bearer ${credential}`, 'x-teams-incarnation': incarnationId }, maxPayload: 16 * 1024 * 1024 });
|
|
@@ -162,11 +163,20 @@ export class MachineCore extends EventEmitter {
|
|
|
162
163
|
} catch (error) { socket.terminate(); dropped(error); }
|
|
163
164
|
});
|
|
164
165
|
socket.once('error', dropped);
|
|
165
|
-
socket.once('close', () =>
|
|
166
|
+
socket.once('close', (_code, reason) => {
|
|
167
|
+
// The server keeps one link per machine and closes the older one as `replaced`: another
|
|
168
|
+
// connector holds this machine now. Dialling back would knock that one off in turn, so
|
|
169
|
+
// this one stops and says so.
|
|
170
|
+
if (String(reason ?? '') === 'replaced') {
|
|
171
|
+
this.linkReplaced = true;
|
|
172
|
+
this.emit('link', { event: 'replaced', error: 'another connector for this machine took over its link; this one stopped linking' });
|
|
173
|
+
}
|
|
174
|
+
dropped(null);
|
|
175
|
+
});
|
|
166
176
|
}
|
|
167
177
|
|
|
168
178
|
#retryLink() {
|
|
169
|
-
if (this.stopping || !this.link || this.linkTimer) return;
|
|
179
|
+
if (this.stopping || !this.link || this.linkTimer || this.linkReplaced) return;
|
|
170
180
|
const delay = this.linkBackoff;
|
|
171
181
|
this.linkBackoff = Math.min(this.linkBackoff * 2, LINK_BACKOFF_MAX_MS);
|
|
172
182
|
this.linkTimer = setTimeout(() => { this.linkTimer = null; this.#connectLink(); }, delay);
|
|
@@ -385,13 +395,14 @@ export class MachineCore extends EventEmitter {
|
|
|
385
395
|
}
|
|
386
396
|
|
|
387
397
|
async function clearStaleSocket(path) {
|
|
388
|
-
|
|
398
|
+
// A named pipe has no file to leave behind: only a live daemon holds its name.
|
|
399
|
+
if (!isNamedPipe(path) && !existsSync(path)) return;
|
|
389
400
|
const live = await new Promise((done) => {
|
|
390
401
|
const probe = netConnect(path);
|
|
391
402
|
probe.once('connect', () => { probe.destroy(); done(true); });
|
|
392
403
|
probe.once('error', () => done(false));
|
|
393
404
|
});
|
|
394
405
|
if (live) throw Object.assign(new Error(`a machine daemon is already serving ${path}`), { code: 'EADDRINUSE' });
|
|
395
|
-
rmSync(path, { force: true });
|
|
406
|
+
if (!isNamedPipe(path)) rmSync(path, { force: true });
|
|
396
407
|
}
|
|
397
408
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/teams",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Volter Teams, the neutral team plane: machine identity and daemon core, the local door and server link, admission from machine grants, product doors, and the Teams server's neutral routes (company RFC 0008).",
|
|
6
6
|
"license": "MIT",
|
package/server/http.mjs
CHANGED
|
@@ -240,19 +240,20 @@ async function enroll(api,req,res,auth,requestId){if(auth.role==='viewer')fail(4
|
|
|
240
240
|
// The same person enrolling a machine of the same name again (a reinstall, a lost home) takes that machine back; the
|
|
241
241
|
// caller then rotates its credential, as for a machine it already knew.
|
|
242
242
|
if(!machine){const own=await api.store.one('SELECT id FROM machines WHERE team_id=? AND custodian_id=? AND name=? AND revoked_at IS NULL',[auth.team_id,auth.principal_id,b.name]);if(own){await api.store.transaction([{sql:'UPDATE machines SET enrollment_key=? WHERE team_id=? AND id=?',params:[b.enrollment_key,auth.team_id,own.id]}]);machine=await byKey();}}
|
|
243
|
-
if(machine){return send(res,200,result(api,auth,{machine:machineJson(machine,api.clock.nowMs()),source:sourceJson({id:machine.source_id,name:machine.source_name,kind:machine.source_kind,state:machine.source_state,machine_id:machine.id,custodian_id:machine.custodian_id,visibility:machine.visibility,revision:machine.source_revision,last_received_at:machine.last_received_at,decoder:machine.decoder,counters_json:machine.counters_json,last_error:machine.last_error}),credential:null}),requestId);}const mid=api.ids.next('machine'),sid=api.ids.next('source'),cid=api.ids.next('credential'),token=makeToken(cid),now=api.clock.nowMs(),data={machine:{id:mid,name:b.name,custodian_id:auth.principal_id,enrollment:'active',connection:'offline',last_seen_at:null,incarnation_id:null,revision:1},source:{id:sid,name:`${b.name} connector`,kind:'connector',state:'active',machine_id:mid,custodian_id:auth.principal_id,default_visibility:'restricted',revision:1,last_received_at:null,decoder:null},credential:{token,kind:'machine',expires_at:now+2592000000}},response=result(api,auth,data);await guardedMutation(api,res,auth,[...authGuards(auth,now),{sql:'SELECT 1 ok WHERE NOT EXISTS (SELECT 1 FROM sources WHERE team_id=? AND name=?)',params:[auth.team_id,`${b.name} connector`],code:'conflict',status:409,message:`a machine named ${b.name} is already enrolled in this team; enroll this one under another --name`}],[{sql:'INSERT INTO machines(team_id,id,name,custodian_id,enrollment_key) VALUES (?,?,?,?,?)',params:[auth.team_id,mid,b.name,auth.principal_id,b.enrollment_key]},{sql:"INSERT INTO sources(team_id,id,name,kind,machine_id,custodian_id,visibility,state) VALUES (?,?,?,?,?,?,?,'active')",params:[auth.team_id,sid,`${b.name} connector`,'connector',mid,auth.principal_id,'restricted']},{sql:'INSERT INTO credentials(id,token_hash,principal_id,kind,team_id,source_id,machine_id,expires_at,created_at) VALUES (?,?,?,?,?,?,?,?,?)',params:[cid,hashToken(token),auth.principal_id,'machine',auth.team_id,sid,mid,now+2592000000,now]}],{status:201,body:response,targetId:mid});send(res,201,response,requestId);}
|
|
244
|
-
|
|
243
|
+
if(machine){return send(res,200,result(api,auth,{machine:machineJson(machine,api.clock.nowMs(),api),source:sourceJson({id:machine.source_id,name:machine.source_name,kind:machine.source_kind,state:machine.source_state,machine_id:machine.id,custodian_id:machine.custodian_id,visibility:machine.visibility,revision:machine.source_revision,last_received_at:machine.last_received_at,decoder:machine.decoder,counters_json:machine.counters_json,last_error:machine.last_error}),credential:null}),requestId);}const mid=api.ids.next('machine'),sid=api.ids.next('source'),cid=api.ids.next('credential'),token=makeToken(cid),now=api.clock.nowMs(),data={machine:{id:mid,name:b.name,custodian_id:auth.principal_id,enrollment:'active',connection:'offline',last_seen_at:null,incarnation_id:null,revision:1},source:{id:sid,name:`${b.name} connector`,kind:'connector',state:'active',machine_id:mid,custodian_id:auth.principal_id,default_visibility:'restricted',revision:1,last_received_at:null,decoder:null},credential:{token,kind:'machine',expires_at:now+2592000000}},response=result(api,auth,data);await guardedMutation(api,res,auth,[...authGuards(auth,now),{sql:'SELECT 1 ok WHERE NOT EXISTS (SELECT 1 FROM sources WHERE team_id=? AND name=?)',params:[auth.team_id,`${b.name} connector`],code:'conflict',status:409,message:`a machine named ${b.name} is already enrolled in this team; enroll this one under another --name`}],[{sql:'INSERT INTO machines(team_id,id,name,custodian_id,enrollment_key) VALUES (?,?,?,?,?)',params:[auth.team_id,mid,b.name,auth.principal_id,b.enrollment_key]},{sql:"INSERT INTO sources(team_id,id,name,kind,machine_id,custodian_id,visibility,state) VALUES (?,?,?,?,?,?,?,'active')",params:[auth.team_id,sid,`${b.name} connector`,'connector',mid,auth.principal_id,'restricted']},{sql:'INSERT INTO credentials(id,token_hash,principal_id,kind,team_id,source_id,machine_id,expires_at,created_at) VALUES (?,?,?,?,?,?,?,?,?)',params:[cid,hashToken(token),auth.principal_id,'machine',auth.team_id,sid,mid,now+2592000000,now]}],{status:201,body:response,targetId:mid});send(res,201,response,requestId);}
|
|
244
|
+
// `online` is a machine that heartbeats and holds its link, so callers can reach it; `unlinked` heartbeats without one.
|
|
245
|
+
const machineJson=(m,now,api)=>({id:m.id,name:m.name,custodian_id:m.custodian_id,enrollment:m.revoked_at==null?'active':'revoked',connection:m.last_seen_at!=null&&m.last_seen_at+45000>now?(!api||api.machineLinks?.has(`${m.team_id}\0${m.id}`)?'online':'unlinked'):'offline',last_seen_at:m.last_seen_at,incarnation_id:m.incarnation_id,host_key_id:m.host_key_id??null,revision:m.revision});
|
|
245
246
|
async function machineAdminRoute(api,req,res,auth,machineId,roots,requestId){
|
|
246
247
|
// Custodians and admins see their machines, revoked ones included; every member sees the team's active machines,
|
|
247
248
|
// each of which they can message (the team's standing mail grant, machine-link.mjs). Roots stay custodial.
|
|
248
249
|
const custody=roots?'(m.custodian_id=? OR ?=1)':'(m.custodian_id=? OR ?=1 OR m.revoked_at IS NULL)',params=[auth.team_id,auth.principal_id,isAdmin(auth)?1:0];
|
|
249
|
-
if(machineId){const machine=await api.store.one(`SELECT m.* FROM machines m WHERE m.team_id=? AND ${custody} AND m.id=?`,[...params,machineId]);if(!machine)fail(404,'not_found','not found');if(roots){const rows=await api.store.query('SELECT value_json FROM machine_roots WHERE team_id=? AND machine_id=? ORDER BY id',[auth.team_id,machineId]);return send(res,200,{version:1,context:{server_id:api.serverId,team_id:auth.team_id,principal_id:auth.principal_id},items:rows.map(row=>JSON.parse(row.value_json)),next_cursor:null},requestId);}return send(res,200,result(api,auth,machineJson(machine,api.clock.nowMs())),requestId,{ETag:`"${machine.revision}"`});}
|
|
250
|
-
const rows=await api.store.query(`SELECT m.* FROM machines m WHERE m.team_id=? AND ${custody} ORDER BY m.name,m.id`,params);send(res,200,{version:1,context:{server_id:api.serverId,team_id:auth.team_id,principal_id:auth.principal_id},items:rows.map(row=>machineJson(row,api.clock.nowMs())),next_cursor:null},requestId);
|
|
250
|
+
if(machineId){const machine=await api.store.one(`SELECT m.* FROM machines m WHERE m.team_id=? AND ${custody} AND m.id=?`,[...params,machineId]);if(!machine)fail(404,'not_found','not found');if(roots){const rows=await api.store.query('SELECT value_json FROM machine_roots WHERE team_id=? AND machine_id=? ORDER BY id',[auth.team_id,machineId]);return send(res,200,{version:1,context:{server_id:api.serverId,team_id:auth.team_id,principal_id:auth.principal_id},items:rows.map(row=>JSON.parse(row.value_json)),next_cursor:null},requestId);}return send(res,200,result(api,auth,machineJson(machine,api.clock.nowMs(),api)),requestId,{ETag:`"${machine.revision}"`});}
|
|
251
|
+
const rows=await api.store.query(`SELECT m.* FROM machines m WHERE m.team_id=? AND ${custody} ORDER BY m.name,m.id`,params);send(res,200,{version:1,context:{server_id:api.serverId,team_id:auth.team_id,principal_id:auth.principal_id},items:rows.map(row=>machineJson(row,api.clock.nowMs(),api)),next_cursor:null},requestId);
|
|
251
252
|
}
|
|
252
253
|
async function revokeMachine(api,req,res,auth,machineId,requestId){
|
|
253
254
|
const machine=await api.store.one('SELECT * FROM machines WHERE team_id=? AND id=?',[auth.team_id,machineId]);if(!machine)fail(404,'not_found','not found');if(machine.custodian_id!==auth.principal_id&&!isAdmin(auth))fail(403,'forbidden','forbidden');requireRevision(req,machine.revision,'machine');const now=api.clock.nowMs(),guards=[...authGuards(auth,now),{sql:'SELECT 1 ok FROM machines WHERE team_id=? AND id=? AND revision=? AND revoked_at IS NULL AND (custodian_id=? OR ?=1)',params:[auth.team_id,machineId,machine.revision,auth.principal_id,isAdmin(auth)?1:0],code:'revision_mismatch',status:409,message:'machine changed'}],statements=[{sql:'UPDATE machines SET revoked_at=?,revision=revision+1 WHERE team_id=? AND id=?',params:[now,auth.team_id,machineId]},{sql:'UPDATE credentials SET revoked_at=? WHERE team_id=? AND machine_id=? AND revoked_at IS NULL',params:[now,auth.team_id,machineId]},{sql:"UPDATE sources SET state='revoked',revision=revision+1 WHERE team_id=? AND machine_id=?",params:[auth.team_id,machineId]},{sql:"UPDATE sync_rules SET state='removed',revision=revision+1 WHERE team_id=? AND machine_id=? AND state!='removed'",params:[auth.team_id,machineId]},{sql:"UPDATE attachments SET state='revoked',closed_at=? WHERE team_id=? AND machine_id=? AND state IN ('pending','active')",params:[now,auth.team_id,machineId]},{sql:"UPDATE operations SET state='unknown' WHERE team_id=? AND id IN (SELECT operation_id FROM native_effects WHERE team_id=? AND machine_id=? AND state IN ('pending','running'))",params:[auth.team_id,auth.team_id,machineId]},{sql:"UPDATE native_effects SET state='unknown',result_json=? WHERE team_id=? AND machine_id=? AND state IN ('pending','running')",params:[JSON.stringify({state:'unknown',error:null}),auth.team_id,machineId]},{sql:'UPDATE teams SET auth_epoch=auth_epoch+1 WHERE id=?',params:[auth.team_id]}];await guardedMutation(api,res,auth,guards,statements,{status:204,body:null,targetId:machineId});api.events.publish(auth.team_id,{type:'authorization_changed',machine_id:machineId});closeMachineChannels(api,auth.team_id,machineId,{reason:'machine_revoked'});send(res,204,null,requestId);
|
|
254
255
|
}
|
|
255
|
-
async function heartbeat(api,req,res,auth,machineId,requestId){if(auth.kind!=='machine'||auth.machine_id!==machineId)fail(403,'forbidden','exact machine credential required');const machine=await api.store.one('SELECT * FROM machines WHERE team_id=? AND id=?',[auth.team_id,machineId]);if(!machine||machine.revoked_at!=null)fail(403,'forbidden','machine enrollment is not active');const b=await parseJson(req);exact(b,['incarnation_id','sequence','bindings','roots','host_key_id']);if(b.host_key_id!==undefined&&(typeof b.host_key_id!=='string'||!/^[A-Za-z0-9_-]{16,128}$/.test(b.host_key_id)))fail(400,'invalid_request','invalid host_key_id');if(typeof b.incarnation_id!=='string'||!Number.isSafeInteger(b.sequence)||b.sequence<1||!Array.isArray(b.bindings)||!Array.isArray(b.roots))fail(400,'invalid_request','invalid heartbeat');if(machine.incarnation_id===b.incarnation_id&&b.sequence<=machine.heartbeat_sequence)return send(res,200,result(api,auth,machineJson(machine,api.clock.nowMs())),requestId);const now=api.clock.nowMs(),newIncarnation=machine.incarnation_id!==null&&machine.incarnation_id!==b.incarnation_id,statements=[{sql:'UPDATE machines SET incarnation_id=?,heartbeat_sequence=?,last_seen_at=?,host_key_id=COALESCE(?,host_key_id),revision=revision+1 WHERE team_id=? AND id=?',params:[b.incarnation_id,b.sequence,now,b.host_key_id??null,auth.team_id,machineId]},{sql:'DELETE FROM machine_roots WHERE team_id=? AND machine_id=?',params:[auth.team_id,machineId]}];if(newIncarnation)statements.push({sql:"UPDATE attachments SET state='closed',closed_at=? WHERE team_id=? AND machine_id=? AND state IN ('pending','active')",params:[now,auth.team_id,machineId]},{sql:"UPDATE operations SET state='unknown' WHERE team_id=? AND id IN (SELECT operation_id FROM native_effects WHERE team_id=? AND machine_id=? AND state IN ('pending','running'))",params:[auth.team_id,auth.team_id,machineId]},{sql:"UPDATE native_effects SET state='unknown',result_json=? WHERE team_id=? AND machine_id=? AND state IN ('pending','running')",params:[JSON.stringify({state:'unknown',error:null}),auth.team_id,machineId]});statements.push({sql:'DELETE FROM bindings WHERE team_id=? AND machine_id=? AND id NOT IN (SELECT binding_id FROM attachments WHERE team_id=? AND machine_id=?) AND id NOT IN (SELECT binding_id FROM native_effects WHERE team_id=? AND machine_id=? AND binding_id IS NOT NULL)',params:[auth.team_id,machineId,auth.team_id,machineId,auth.team_id,machineId]});for(const root of b.roots){exact(root,['id','machine_id','name','harnesses'],'root');if(root.machine_id!==machineId)fail(400,'invalid_request','root machine mismatch');statements.push({sql:'INSERT INTO machine_roots(team_id,machine_id,id,value_json) VALUES (?,?,?,?)',params:[auth.team_id,machineId,root.id,JSON.stringify(root)]});}for(const binding of b.bindings)statements.push({sql:'INSERT INTO bindings(team_id,id,machine_id,session_id,incarnation_id,descriptor_json,observed_at) SELECT ?,?,?,o.session_id,?,?,? FROM origins o WHERE o.team_id=? AND o.source_id=? AND o.harness=? AND o.native_id=? AND o.deleted_at IS NULL ON CONFLICT(team_id,id) DO UPDATE SET machine_id=excluded.machine_id,session_id=excluded.session_id,incarnation_id=excluded.incarnation_id,descriptor_json=excluded.descriptor_json,observed_at=excluded.observed_at',params:[auth.team_id,binding.id,machineId,b.incarnation_id,JSON.stringify(binding),now,auth.team_id,auth.source_id,binding.harness,binding.native_session_id]});const updated={...machine,incarnation_id:b.incarnation_id,heartbeat_sequence:b.sequence,last_seen_at:now,revision:machine.revision+1},response=result(api,auth,machineJson(updated,now)),guards=[{sql:"SELECT 1 ok FROM credentials c JOIN machines m ON m.team_id=c.team_id AND m.id=c.machine_id JOIN sources s ON s.team_id=c.team_id AND s.id=c.source_id JOIN memberships mem ON mem.team_id=m.team_id AND mem.principal_id=m.custodian_id WHERE c.id=? AND c.kind='machine' AND c.machine_id=? AND c.revoked_at IS NULL AND (c.expires_at IS NULL OR c.expires_at>?) AND m.revoked_at IS NULL AND mem.active=1 AND s.state!='revoked' AND (m.incarnation_id IS NULL OR m.incarnation_id!=? OR m.heartbeat_sequence<?)",params:[auth.credential_id,machineId,now,b.incarnation_id,b.sequence],code:'authentication_required',status:401,message:'machine authorization or heartbeat sequence changed'}];await guardedMutation(api,res,auth,guards,statements,{status:200,body:response,targetId:machineId});send(res,200,response,requestId);}
|
|
256
|
+
async function heartbeat(api,req,res,auth,machineId,requestId){if(auth.kind!=='machine'||auth.machine_id!==machineId)fail(403,'forbidden','exact machine credential required');const machine=await api.store.one('SELECT * FROM machines WHERE team_id=? AND id=?',[auth.team_id,machineId]);if(!machine||machine.revoked_at!=null)fail(403,'forbidden','machine enrollment is not active');const b=await parseJson(req);exact(b,['incarnation_id','sequence','bindings','roots','host_key_id']);if(b.host_key_id!==undefined&&(typeof b.host_key_id!=='string'||!/^[A-Za-z0-9_-]{16,128}$/.test(b.host_key_id)))fail(400,'invalid_request','invalid host_key_id');if(typeof b.incarnation_id!=='string'||!Number.isSafeInteger(b.sequence)||b.sequence<1||!Array.isArray(b.bindings)||!Array.isArray(b.roots))fail(400,'invalid_request','invalid heartbeat');if(machine.incarnation_id===b.incarnation_id&&b.sequence<=machine.heartbeat_sequence)return send(res,200,result(api,auth,machineJson(machine,api.clock.nowMs(),api)),requestId);const now=api.clock.nowMs(),newIncarnation=machine.incarnation_id!==null&&machine.incarnation_id!==b.incarnation_id,statements=[{sql:'UPDATE machines SET incarnation_id=?,heartbeat_sequence=?,last_seen_at=?,host_key_id=COALESCE(?,host_key_id),revision=revision+1 WHERE team_id=? AND id=?',params:[b.incarnation_id,b.sequence,now,b.host_key_id??null,auth.team_id,machineId]},{sql:'DELETE FROM machine_roots WHERE team_id=? AND machine_id=?',params:[auth.team_id,machineId]}];if(newIncarnation)statements.push({sql:"UPDATE attachments SET state='closed',closed_at=? WHERE team_id=? AND machine_id=? AND state IN ('pending','active')",params:[now,auth.team_id,machineId]},{sql:"UPDATE operations SET state='unknown' WHERE team_id=? AND id IN (SELECT operation_id FROM native_effects WHERE team_id=? AND machine_id=? AND state IN ('pending','running'))",params:[auth.team_id,auth.team_id,machineId]},{sql:"UPDATE native_effects SET state='unknown',result_json=? WHERE team_id=? AND machine_id=? AND state IN ('pending','running')",params:[JSON.stringify({state:'unknown',error:null}),auth.team_id,machineId]});statements.push({sql:'DELETE FROM bindings WHERE team_id=? AND machine_id=? AND id NOT IN (SELECT binding_id FROM attachments WHERE team_id=? AND machine_id=?) AND id NOT IN (SELECT binding_id FROM native_effects WHERE team_id=? AND machine_id=? AND binding_id IS NOT NULL)',params:[auth.team_id,machineId,auth.team_id,machineId,auth.team_id,machineId]});for(const root of b.roots){exact(root,['id','machine_id','name','harnesses'],'root');if(root.machine_id!==machineId)fail(400,'invalid_request','root machine mismatch');statements.push({sql:'INSERT INTO machine_roots(team_id,machine_id,id,value_json) VALUES (?,?,?,?)',params:[auth.team_id,machineId,root.id,JSON.stringify(root)]});}for(const binding of b.bindings)statements.push({sql:'INSERT INTO bindings(team_id,id,machine_id,session_id,incarnation_id,descriptor_json,observed_at) SELECT ?,?,?,o.session_id,?,?,? FROM origins o WHERE o.team_id=? AND o.source_id=? AND o.harness=? AND o.native_id=? AND o.deleted_at IS NULL ON CONFLICT(team_id,id) DO UPDATE SET machine_id=excluded.machine_id,session_id=excluded.session_id,incarnation_id=excluded.incarnation_id,descriptor_json=excluded.descriptor_json,observed_at=excluded.observed_at',params:[auth.team_id,binding.id,machineId,b.incarnation_id,JSON.stringify(binding),now,auth.team_id,auth.source_id,binding.harness,binding.native_session_id]});const updated={...machine,incarnation_id:b.incarnation_id,heartbeat_sequence:b.sequence,last_seen_at:now,revision:machine.revision+1},response=result(api,auth,machineJson(updated,now,api)),guards=[{sql:"SELECT 1 ok FROM credentials c JOIN machines m ON m.team_id=c.team_id AND m.id=c.machine_id JOIN sources s ON s.team_id=c.team_id AND s.id=c.source_id JOIN memberships mem ON mem.team_id=m.team_id AND mem.principal_id=m.custodian_id WHERE c.id=? AND c.kind='machine' AND c.machine_id=? AND c.revoked_at IS NULL AND (c.expires_at IS NULL OR c.expires_at>?) AND m.revoked_at IS NULL AND mem.active=1 AND s.state!='revoked' AND (m.incarnation_id IS NULL OR m.incarnation_id!=? OR m.heartbeat_sequence<?)",params:[auth.credential_id,machineId,now,b.incarnation_id,b.sequence],code:'authentication_required',status:401,message:'machine authorization or heartbeat sequence changed'}];await guardedMutation(api,res,auth,guards,statements,{status:200,body:response,targetId:machineId});send(res,200,response,requestId);}
|
|
256
257
|
async function machineConfiguration(api,res,auth,machineId,requestId){
|
|
257
258
|
if(auth.kind!=='machine'||auth.machine_id!==machineId)fail(auth.kind==='machine'?404:403,auth.kind==='machine'?'not_found':'forbidden','not found');
|
|
258
259
|
const [machine,rules]=await api.store.snapshot([
|
|
@@ -261,7 +262,7 @@ async function machineConfiguration(api,res,auth,machineId,requestId){
|
|
|
261
262
|
]);
|
|
262
263
|
if(!machine)fail(404,'not_found','not found');
|
|
263
264
|
if(machine.source_state==='revoked')fail(403,'source_revoked','source revoked');
|
|
264
|
-
send(res,200,result(api,auth,{machine:machineJson(machine,api.clock.nowMs()),source:sourceJson({id:machine.source_id,name:machine.source_name,kind:machine.source_kind,state:machine.source_state,machine_id:machineId,custodian_id:machine.custodian_id,visibility:machine.visibility,revision:machine.source_revision,last_received_at:machine.last_received_at,decoder:machine.decoder,counters_json:machine.counters_json,last_error:machine.last_error}),rules:rules.map(syncRuleJson),custodian_id:machine.custodian_id,machine_grants:await machineGrantRows(api,auth.team_id,machineId),authorization_revision:machine.auth_epoch}),requestId);
|
|
265
|
+
send(res,200,result(api,auth,{machine:machineJson(machine,api.clock.nowMs(),api),source:sourceJson({id:machine.source_id,name:machine.source_name,kind:machine.source_kind,state:machine.source_state,machine_id:machineId,custodian_id:machine.custodian_id,visibility:machine.visibility,revision:machine.source_revision,last_received_at:machine.last_received_at,decoder:machine.decoder,counters_json:machine.counters_json,last_error:machine.last_error}),rules:rules.map(syncRuleJson),custodian_id:machine.custodian_id,machine_grants:await machineGrantRows(api,auth.team_id,machineId),authorization_revision:machine.auth_epoch}),requestId);
|
|
265
266
|
}
|
|
266
267
|
const syncRuleJson=r=>({id:r.id,machine_id:r.machine_id,source_id:r.source_id,selector:JSON.parse(r.selector_json),visibility:r.visibility,backfill:JSON.parse(r.backfill_json),state:r.state,revision:r.revision,capture_after:r.capture_after});
|
|
267
268
|
async function createSource(api,req,res,auth,requestId){if(!isAdmin(auth))fail(403,'forbidden','forbidden');const b=await parseJson(req);exact(b,['name','kind','custodian_id','visibility']);if(b.kind!=='otlp'||!['team','restricted'].includes(b.visibility))fail(400,'invalid_request','only otlp sources may be created');const id=api.ids.next('source'),now=api.clock.nowMs(),response=result(api,auth,{id,name:b.name,kind:'otlp',state:'active',machine_id:null,custodian_id:b.custodian_id,default_visibility:b.visibility,revision:1,last_received_at:null,decoder:null}),guards=[...authGuards(auth,now),{sql:'SELECT 1 ok FROM memberships WHERE team_id=? AND principal_id=? AND active=1',params:[auth.team_id,b.custodian_id],code:'invalid_request',status:400,message:'source custodian must be active'}];await guardedMutation(api,res,auth,guards,[{sql:"INSERT INTO sources(team_id,id,name,kind,custodian_id,visibility,state) VALUES (?,?,?,?,?,?,'active')",params:[auth.team_id,id,b.name,'otlp',b.custodian_id,b.visibility]}],{status:201,body:response,targetId:id});send(res,201,response,requestId);}
|