edge-ai-client-ts 1.0.0
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/CHANGELOG.md +72 -0
- package/LICENSE +21 -0
- package/README.md +174 -0
- package/bin/ec-ts.js +18 -0
- package/dist/buffer/disk-queue.d.ts +140 -0
- package/dist/buffer/disk-queue.js +370 -0
- package/dist/cli/devices.d.ts +1 -0
- package/dist/cli/devices.js +61 -0
- package/dist/cli/enroll.d.ts +2 -0
- package/dist/cli/enroll.js +89 -0
- package/dist/cli/index.d.ts +10 -0
- package/dist/cli/index.js +116 -0
- package/dist/cli/messages.d.ts +1 -0
- package/dist/cli/messages.js +59 -0
- package/dist/cli/run.d.ts +5 -0
- package/dist/cli/run.js +112 -0
- package/dist/cli/status.d.ts +1 -0
- package/dist/cli/status.js +56 -0
- package/dist/cli/whoami.d.ts +2 -0
- package/dist/cli/whoami.js +41 -0
- package/dist/config/cmd-gate.d.ts +65 -0
- package/dist/config/cmd-gate.js +128 -0
- package/dist/config/settings.d.ts +209 -0
- package/dist/config/settings.js +627 -0
- package/dist/crypto/aes-gcm.d.ts +38 -0
- package/dist/crypto/aes-gcm.js +90 -0
- package/dist/crypto/hmac.d.ts +31 -0
- package/dist/crypto/hmac.js +52 -0
- package/dist/crypto/tls-guard.d.ts +36 -0
- package/dist/crypto/tls-guard.js +54 -0
- package/dist/daemon/manager.d.ts +82 -0
- package/dist/daemon/manager.js +461 -0
- package/dist/index.d.ts +39 -0
- package/dist/index.js +63 -0
- package/dist/logging/file-logger.d.ts +21 -0
- package/dist/logging/file-logger.js +71 -0
- package/dist/network/ws-client.d.ts +221 -0
- package/dist/network/ws-client.js +1134 -0
- package/dist/session/fail-fast.d.ts +70 -0
- package/dist/session/fail-fast.js +122 -0
- package/dist/session/manager.d.ts +136 -0
- package/dist/session/manager.js +291 -0
- package/dist/session/persistence.d.ts +103 -0
- package/dist/session/persistence.js +194 -0
- package/dist/session/pi-rpc.d.ts +164 -0
- package/dist/session/pi-rpc.js +412 -0
- package/dist/session/sftp.d.ts +64 -0
- package/dist/session/sftp.js +335 -0
- package/dist/session/shell-frame.d.ts +77 -0
- package/dist/session/shell-frame.js +199 -0
- package/dist/session/shell.d.ts +124 -0
- package/dist/session/shell.js +300 -0
- package/docs/CONFIGURATION.md +169 -0
- package/docs/INSTALLATION.md +164 -0
- package/docs/PROTOCOL.md +248 -0
- package/docs/TROUBLESHOOTING.md +177 -0
- package/package.json +79 -0
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Edge-client configuration — mirrors `edge-client/src/config/settings.rs`,
|
|
3
|
+
* `machine_id.rs`, `device_secret.rs`, and `mod.rs`.
|
|
4
|
+
*
|
|
5
|
+
* All field names EXACTLY match the Rust struct fields (snake_case), so a
|
|
6
|
+
* `config.toml` written by the Rust client round-trips through the TS client
|
|
7
|
+
* and vice versa.
|
|
8
|
+
*
|
|
9
|
+
* Security invariants preserved from Rust:
|
|
10
|
+
* - `machine_id`: UUIDv4, generated once, persisted atomically.
|
|
11
|
+
* - `device_secret`: atomic write, chmod 600 on Unix.
|
|
12
|
+
* - `relay.device_secret`: `skip_serializing` — never written to config.toml.
|
|
13
|
+
* - `enforceHostnameOrToken`: refuse to start with empty machine_id + no token.
|
|
14
|
+
* - Buffer key + edge-auth-token resolution: env var wins over config.
|
|
15
|
+
*/
|
|
16
|
+
import { randomUUID } from 'node:crypto';
|
|
17
|
+
import * as fs from 'node:fs';
|
|
18
|
+
import * as os from 'node:os';
|
|
19
|
+
import * as path from 'node:path';
|
|
20
|
+
import { parse as parseToml, stringify as stringifyToml } from 'smol-toml';
|
|
21
|
+
import { defaultCmdGateConfig } from './cmd-gate.js';
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Defaults
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
function defaultShellConfig() {
|
|
26
|
+
return { enabled: false, default_cols: 80, default_rows: 24 };
|
|
27
|
+
}
|
|
28
|
+
function defaultResourcesConfig() {
|
|
29
|
+
return { memory_max_mb: 2048, cpu_quota_percent: 50, tasks_max: 256 };
|
|
30
|
+
}
|
|
31
|
+
/** Construct the default Settings (factory). Mirrors Rust `Settings::default()`. */
|
|
32
|
+
export function defaultSettings() {
|
|
33
|
+
return {
|
|
34
|
+
server: {
|
|
35
|
+
ws_url: 'ws://localhost:3001/edge',
|
|
36
|
+
},
|
|
37
|
+
agent: {
|
|
38
|
+
default_agent_id: 'worker_default',
|
|
39
|
+
},
|
|
40
|
+
buffer: {
|
|
41
|
+
max_memory_mb: 50,
|
|
42
|
+
flush_interval_ms: 100,
|
|
43
|
+
ack_timeout_ms: 5000,
|
|
44
|
+
max_retries: 3,
|
|
45
|
+
},
|
|
46
|
+
logging: {
|
|
47
|
+
level: 'info',
|
|
48
|
+
local_log_dir: 'logs',
|
|
49
|
+
},
|
|
50
|
+
machine_id: '',
|
|
51
|
+
shell: defaultShellConfig(),
|
|
52
|
+
relay: {},
|
|
53
|
+
resources: defaultResourcesConfig(),
|
|
54
|
+
cmd_gate: defaultCmdGateConfig(),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// Runtime guards (zero `any`)
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
function isRecord(value) {
|
|
61
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
62
|
+
}
|
|
63
|
+
function asString(value) {
|
|
64
|
+
return typeof value === 'string' ? value : undefined;
|
|
65
|
+
}
|
|
66
|
+
function asNonEmptyString(value) {
|
|
67
|
+
if (typeof value === 'string' && value.length > 0) {
|
|
68
|
+
return value;
|
|
69
|
+
}
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
function asNumber(value) {
|
|
73
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
74
|
+
return value;
|
|
75
|
+
}
|
|
76
|
+
// TOML integers may arrive as bigint via smol-toml
|
|
77
|
+
if (typeof value === 'bigint') {
|
|
78
|
+
return Number(value);
|
|
79
|
+
}
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
function asBoolean(value) {
|
|
83
|
+
return typeof value === 'boolean' ? value : undefined;
|
|
84
|
+
}
|
|
85
|
+
function asStringArray(value) {
|
|
86
|
+
if (!Array.isArray(value)) {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
return value.filter((v) => typeof v === 'string');
|
|
90
|
+
}
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// Config directory resolution
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
/**
|
|
95
|
+
* The edge-ai-agent config directory. Mirrors Rust's `dirs::config_dir()`:
|
|
96
|
+
* - Linux: `$XDG_CONFIG_HOME/edge-ai-agent` or `~/.config/edge-ai-agent`
|
|
97
|
+
* - macOS: `~/Library/Application Support/edge-ai-agent`
|
|
98
|
+
* - Windows: `%APPDATA%\edge-ai-agent`
|
|
99
|
+
*
|
|
100
|
+
* Creates the directory if missing.
|
|
101
|
+
*/
|
|
102
|
+
export function configDir() {
|
|
103
|
+
const home = os.homedir();
|
|
104
|
+
let base;
|
|
105
|
+
if (process.platform === 'win32') {
|
|
106
|
+
base = process.env.APPDATA ?? path.join(home, 'AppData', 'Roaming');
|
|
107
|
+
}
|
|
108
|
+
else if (process.platform === 'darwin') {
|
|
109
|
+
base = path.join(home, 'Library', 'Application Support');
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
// Linux / other Unix — XDG
|
|
113
|
+
base = process.env.XDG_CONFIG_HOME ?? path.join(home, '.config');
|
|
114
|
+
}
|
|
115
|
+
const dir = path.join(base, 'edge-ai-agent');
|
|
116
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
117
|
+
return dir;
|
|
118
|
+
}
|
|
119
|
+
/** Path to `config.toml`. */
|
|
120
|
+
export function configPath() {
|
|
121
|
+
return path.join(configDir(), 'config.toml');
|
|
122
|
+
}
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
// Atomic file writes
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
/**
|
|
127
|
+
* Write `data` to `filePath` atomically: write a sibling `.tmp` file then
|
|
128
|
+
* rename over the destination. Prevents torn writes. Mirrors the Rust
|
|
129
|
+
* `atomic_write` pattern in `disk_dump.rs` / `persistence.rs`.
|
|
130
|
+
*
|
|
131
|
+
* On Unix, optionally restricts the file to owner-only (0600).
|
|
132
|
+
*/
|
|
133
|
+
export function atomicWriteFile(filePath, data, options) {
|
|
134
|
+
const dir = path.dirname(filePath);
|
|
135
|
+
const name = path.basename(filePath);
|
|
136
|
+
const tmp = path.join(dir, `.${name}.tmp`);
|
|
137
|
+
const encoding = typeof data === 'string' ? 'utf8' : undefined;
|
|
138
|
+
// H2 — SECURITY FIX (security-review 2026-07-11): eliminate the
|
|
139
|
+
// writeFileSync → chmodSync TOCTOU window where the temp file would
|
|
140
|
+
// briefly exist world-readable (default mode 0644 on Linux) before
|
|
141
|
+
// the chmod tightens it. We open the temp file with the desired mode
|
|
142
|
+
// atomically, write via the fd, then close + rename — no other process
|
|
143
|
+
// sees a permissive-mode intermediate state. Mirrors the
|
|
144
|
+
// disk-queue.ts pattern (`fs.open(path, 'w', 0o600)`).
|
|
145
|
+
const mode = options?.mode ?? 0o644;
|
|
146
|
+
const fd = fs.openSync(tmp, 'w', mode);
|
|
147
|
+
try {
|
|
148
|
+
// fs.writeSync has separate overloads for string vs Buffer — branch on type
|
|
149
|
+
// so TypeScript picks the correct one. The `encoding` param is only used
|
|
150
|
+
// for the string branch (Buffer is always binary).
|
|
151
|
+
if (typeof data === 'string') {
|
|
152
|
+
fs.writeSync(fd, data, undefined, encoding);
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
fs.writeSync(fd, data);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
finally {
|
|
159
|
+
fs.closeSync(fd);
|
|
160
|
+
}
|
|
161
|
+
fs.renameSync(tmp, filePath);
|
|
162
|
+
}
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
// machine_id — UUIDv4 persisted atomically (mirrors machine_id.rs)
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
/**
|
|
167
|
+
* Read-or-create the persisted machine_id inside `configDir`.
|
|
168
|
+
*
|
|
169
|
+
* 1. If `<configDir>/machine_id` exists and is non-empty after trim → return it.
|
|
170
|
+
* 2. Otherwise: create `configDir`, mint a fresh UUIDv4, atomically persist.
|
|
171
|
+
*
|
|
172
|
+
* Pure inner helper (takes explicit dir) for unit-testability.
|
|
173
|
+
*/
|
|
174
|
+
export function generateMachineIdForDir(configDirPath) {
|
|
175
|
+
const idPath = path.join(configDirPath, 'machine_id');
|
|
176
|
+
// Persistence fast path
|
|
177
|
+
if (fs.existsSync(idPath)) {
|
|
178
|
+
try {
|
|
179
|
+
const content = fs.readFileSync(idPath, 'utf8');
|
|
180
|
+
const trimmed = content.trim();
|
|
181
|
+
if (trimmed.length > 0) {
|
|
182
|
+
return trimmed;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
// fall through to generation
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
// Ensure config dir exists
|
|
190
|
+
fs.mkdirSync(configDirPath, { recursive: true });
|
|
191
|
+
const id = randomUUID();
|
|
192
|
+
atomicWriteFile(idPath, id);
|
|
193
|
+
return id;
|
|
194
|
+
}
|
|
195
|
+
/** Public entry point: resolve config dir and delegate. Mirrors `generate()`. */
|
|
196
|
+
export function generateMachineId() {
|
|
197
|
+
return generateMachineIdForDir(configDir());
|
|
198
|
+
}
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
// device_secret — per-device credential persistence (mirrors device_secret.rs)
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
/**
|
|
203
|
+
* Read the persisted device secret from `configDir`, or `undefined`.
|
|
204
|
+
* Pure inner helper.
|
|
205
|
+
*/
|
|
206
|
+
export function loadDeviceSecretForDir(configDirPath) {
|
|
207
|
+
const secretPath = path.join(configDirPath, 'device_secret');
|
|
208
|
+
try {
|
|
209
|
+
const content = fs.readFileSync(secretPath, 'utf8');
|
|
210
|
+
const trimmed = content.trim();
|
|
211
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
return undefined;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
/** Read the persisted device secret from the real config dir, or `undefined`. */
|
|
218
|
+
export function loadDeviceSecret() {
|
|
219
|
+
return loadDeviceSecretForDir(configDir());
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Persist a device secret to `configDir` atomically. Overwrites existing.
|
|
223
|
+
* Pure inner helper.
|
|
224
|
+
*/
|
|
225
|
+
export function storeDeviceSecretForDir(configDirPath, secret) {
|
|
226
|
+
fs.mkdirSync(configDirPath, { recursive: true });
|
|
227
|
+
const secretPath = path.join(configDirPath, 'device_secret');
|
|
228
|
+
atomicWriteFile(secretPath, secret.trim(), { mode: 0o600 });
|
|
229
|
+
}
|
|
230
|
+
/** Persist a device secret to the real config dir (atomic write). */
|
|
231
|
+
export function storeDeviceSecret(secret) {
|
|
232
|
+
storeDeviceSecretForDir(configDir(), secret);
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Restrict the persisted secret file to owner-only (0600). Best-effort on
|
|
236
|
+
* Windows (no-op). Mirrors `restrict_perms`.
|
|
237
|
+
*/
|
|
238
|
+
export function restrictDeviceSecretPerms(configDirPath) {
|
|
239
|
+
if (process.platform === 'win32')
|
|
240
|
+
return;
|
|
241
|
+
const secretPath = path.join(configDirPath, 'device_secret');
|
|
242
|
+
if (fs.existsSync(secretPath)) {
|
|
243
|
+
try {
|
|
244
|
+
fs.chmodSync(secretPath, 0o600);
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
// best-effort
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
// Settings serialization (TOML / JSON)
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
/**
|
|
255
|
+
* Strip `device_secret` before serialization (mirrors Rust's
|
|
256
|
+
* `#[serde(skip_serializing)]`). Also strips any `undefined` optional fields.
|
|
257
|
+
*/
|
|
258
|
+
function settingsToSerializable(settings) {
|
|
259
|
+
const relayCopy = {};
|
|
260
|
+
if (settings.relay.auth_token !== undefined) {
|
|
261
|
+
relayCopy.auth_token = settings.relay.auth_token;
|
|
262
|
+
}
|
|
263
|
+
// device_secret intentionally omitted (skip_serializing)
|
|
264
|
+
if (settings.relay.fingerprint !== undefined) {
|
|
265
|
+
relayCopy.fingerprint = settings.relay.fingerprint;
|
|
266
|
+
}
|
|
267
|
+
const bufferCopy = {
|
|
268
|
+
max_memory_mb: settings.buffer.max_memory_mb,
|
|
269
|
+
flush_interval_ms: settings.buffer.flush_interval_ms,
|
|
270
|
+
ack_timeout_ms: settings.buffer.ack_timeout_ms,
|
|
271
|
+
max_retries: settings.buffer.max_retries,
|
|
272
|
+
};
|
|
273
|
+
if (settings.buffer.key !== undefined) {
|
|
274
|
+
bufferCopy.key = settings.buffer.key;
|
|
275
|
+
}
|
|
276
|
+
if (settings.buffer.dump_dir !== undefined) {
|
|
277
|
+
bufferCopy.dump_dir = settings.buffer.dump_dir;
|
|
278
|
+
}
|
|
279
|
+
const serverCopy = {
|
|
280
|
+
ws_url: settings.server.ws_url,
|
|
281
|
+
};
|
|
282
|
+
if (settings.server.ws_url_lan !== undefined) {
|
|
283
|
+
serverCopy.ws_url_lan = settings.server.ws_url_lan;
|
|
284
|
+
}
|
|
285
|
+
if (settings.server.ws_url_wan !== undefined) {
|
|
286
|
+
serverCopy.ws_url_wan = settings.server.ws_url_wan;
|
|
287
|
+
}
|
|
288
|
+
const result = {
|
|
289
|
+
server: serverCopy,
|
|
290
|
+
agent: settings.agent,
|
|
291
|
+
buffer: bufferCopy,
|
|
292
|
+
logging: settings.logging,
|
|
293
|
+
machine_id: settings.machine_id,
|
|
294
|
+
shell: settings.shell,
|
|
295
|
+
relay: relayCopy,
|
|
296
|
+
resources: settings.resources,
|
|
297
|
+
cmd_gate: settings.cmd_gate,
|
|
298
|
+
};
|
|
299
|
+
if (settings.owner_operator_id !== undefined) {
|
|
300
|
+
result.owner_operator_id = settings.owner_operator_id;
|
|
301
|
+
}
|
|
302
|
+
if (settings.pi_path !== undefined) {
|
|
303
|
+
result.pi_path = settings.pi_path;
|
|
304
|
+
}
|
|
305
|
+
return result;
|
|
306
|
+
}
|
|
307
|
+
/** Persist the current settings to `config.toml`. Mirrors Rust `save()`. */
|
|
308
|
+
export function saveSettings(settings) {
|
|
309
|
+
const cfgPath = configPath();
|
|
310
|
+
const obj = settingsToSerializable(settings);
|
|
311
|
+
const content = stringifyToml(obj);
|
|
312
|
+
fs.writeFileSync(cfgPath, content, 'utf8');
|
|
313
|
+
}
|
|
314
|
+
// ---------------------------------------------------------------------------
|
|
315
|
+
// Settings deserialization (TOML or JSON fallback)
|
|
316
|
+
// ---------------------------------------------------------------------------
|
|
317
|
+
/**
|
|
318
|
+
* Parse a TOML or JSON config string into a validated `Settings`.
|
|
319
|
+
* Missing fields fall back to defaults. Mirrors Rust `toml::from_str`.
|
|
320
|
+
*/
|
|
321
|
+
export function parseSettings(content) {
|
|
322
|
+
let raw;
|
|
323
|
+
const trimmed = content.trimStart();
|
|
324
|
+
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
|
325
|
+
raw = JSON.parse(content);
|
|
326
|
+
}
|
|
327
|
+
else {
|
|
328
|
+
raw = parseToml(content);
|
|
329
|
+
}
|
|
330
|
+
if (!isRecord(raw)) {
|
|
331
|
+
throw new Error('Config root is not an object');
|
|
332
|
+
}
|
|
333
|
+
return settingsFromRecord(raw);
|
|
334
|
+
}
|
|
335
|
+
function settingsFromRecord(raw) {
|
|
336
|
+
const d = defaultSettings();
|
|
337
|
+
const serverRaw = isRecord(raw['server']) ? raw['server'] : {};
|
|
338
|
+
const agentRaw = isRecord(raw['agent']) ? raw['agent'] : {};
|
|
339
|
+
const bufferRaw = isRecord(raw['buffer']) ? raw['buffer'] : {};
|
|
340
|
+
const loggingRaw = isRecord(raw['logging']) ? raw['logging'] : {};
|
|
341
|
+
const shellRaw = isRecord(raw['shell']) ? raw['shell'] : {};
|
|
342
|
+
const relayRaw = isRecord(raw['relay']) ? raw['relay'] : {};
|
|
343
|
+
const resourcesRaw = isRecord(raw['resources']) ? raw['resources'] : {};
|
|
344
|
+
const cmdGateRaw = isRecord(raw['cmd_gate']) ? raw['cmd_gate'] : {};
|
|
345
|
+
// Build ServerConfig with conditional optional fields
|
|
346
|
+
const server = {
|
|
347
|
+
ws_url: asNonEmptyString(serverRaw['ws_url']) ?? d.server.ws_url,
|
|
348
|
+
};
|
|
349
|
+
const wsUrlLan = asNonEmptyString(serverRaw['ws_url_lan']);
|
|
350
|
+
if (wsUrlLan !== undefined) {
|
|
351
|
+
server.ws_url_lan = wsUrlLan;
|
|
352
|
+
}
|
|
353
|
+
const wsUrlWan = asNonEmptyString(serverRaw['ws_url_wan']);
|
|
354
|
+
if (wsUrlWan !== undefined) {
|
|
355
|
+
server.ws_url_wan = wsUrlWan;
|
|
356
|
+
}
|
|
357
|
+
// BufferConfig
|
|
358
|
+
const buffer = {
|
|
359
|
+
max_memory_mb: asNumber(bufferRaw['max_memory_mb']) ?? d.buffer.max_memory_mb,
|
|
360
|
+
flush_interval_ms: asNumber(bufferRaw['flush_interval_ms']) ?? d.buffer.flush_interval_ms,
|
|
361
|
+
ack_timeout_ms: asNumber(bufferRaw['ack_timeout_ms']) ?? d.buffer.ack_timeout_ms,
|
|
362
|
+
max_retries: asNumber(bufferRaw['max_retries']) ?? d.buffer.max_retries,
|
|
363
|
+
};
|
|
364
|
+
const bufferKey = asNonEmptyString(bufferRaw['key']);
|
|
365
|
+
if (bufferKey !== undefined) {
|
|
366
|
+
buffer.key = bufferKey;
|
|
367
|
+
}
|
|
368
|
+
const dumpDir = asNonEmptyString(bufferRaw['dump_dir']);
|
|
369
|
+
if (dumpDir !== undefined) {
|
|
370
|
+
buffer.dump_dir = dumpDir;
|
|
371
|
+
}
|
|
372
|
+
// ShellConfig
|
|
373
|
+
const shell = {
|
|
374
|
+
enabled: asBoolean(shellRaw['enabled']) ?? d.shell.enabled,
|
|
375
|
+
default_cols: asNumber(shellRaw['default_cols']) ?? d.shell.default_cols,
|
|
376
|
+
default_rows: asNumber(shellRaw['default_rows']) ?? d.shell.default_rows,
|
|
377
|
+
};
|
|
378
|
+
// RelayConfig
|
|
379
|
+
const relay = {};
|
|
380
|
+
const authToken = asNonEmptyString(relayRaw['auth_token']);
|
|
381
|
+
if (authToken !== undefined) {
|
|
382
|
+
relay.auth_token = authToken;
|
|
383
|
+
}
|
|
384
|
+
const deviceSecret = asNonEmptyString(relayRaw['device_secret']);
|
|
385
|
+
if (deviceSecret !== undefined) {
|
|
386
|
+
relay.device_secret = deviceSecret;
|
|
387
|
+
}
|
|
388
|
+
const fingerprint = asNonEmptyString(relayRaw['fingerprint']);
|
|
389
|
+
if (fingerprint !== undefined) {
|
|
390
|
+
relay.fingerprint = fingerprint;
|
|
391
|
+
}
|
|
392
|
+
// ResourcesConfig
|
|
393
|
+
const resources = {
|
|
394
|
+
memory_max_mb: asNumber(resourcesRaw['memory_max_mb']) ?? d.resources.memory_max_mb,
|
|
395
|
+
cpu_quota_percent: asNumber(resourcesRaw['cpu_quota_percent']) ?? d.resources.cpu_quota_percent,
|
|
396
|
+
tasks_max: asNumber(resourcesRaw['tasks_max']) ?? d.resources.tasks_max,
|
|
397
|
+
};
|
|
398
|
+
// CmdGateConfig
|
|
399
|
+
const cmdGate = parseCmdGateRecord(cmdGateRaw, d.cmd_gate);
|
|
400
|
+
// Top-level fields
|
|
401
|
+
const machine_id = asString(raw['machine_id']) ?? '';
|
|
402
|
+
const owner_operator_id = asNonEmptyString(raw['owner_operator_id']);
|
|
403
|
+
const pi_path = asNonEmptyString(raw['pi_path']);
|
|
404
|
+
const settings = {
|
|
405
|
+
server,
|
|
406
|
+
agent: {
|
|
407
|
+
default_agent_id: asNonEmptyString(agentRaw['default_agent_id']) ?? d.agent.default_agent_id,
|
|
408
|
+
},
|
|
409
|
+
buffer,
|
|
410
|
+
logging: {
|
|
411
|
+
level: asNonEmptyString(loggingRaw['level']) ?? d.logging.level,
|
|
412
|
+
local_log_dir: asNonEmptyString(loggingRaw['local_log_dir']) ?? d.logging.local_log_dir,
|
|
413
|
+
},
|
|
414
|
+
machine_id,
|
|
415
|
+
shell,
|
|
416
|
+
relay,
|
|
417
|
+
resources,
|
|
418
|
+
cmd_gate: cmdGate,
|
|
419
|
+
};
|
|
420
|
+
if (owner_operator_id !== undefined) {
|
|
421
|
+
settings.owner_operator_id = owner_operator_id;
|
|
422
|
+
}
|
|
423
|
+
if (pi_path !== undefined) {
|
|
424
|
+
settings.pi_path = pi_path;
|
|
425
|
+
}
|
|
426
|
+
return settings;
|
|
427
|
+
}
|
|
428
|
+
function parseCmdGateRecord(raw, fallback) {
|
|
429
|
+
const modeRaw = asString(raw['mode']);
|
|
430
|
+
const mode = modeRaw === 'off' || modeRaw === 'warn' || modeRaw === 'block'
|
|
431
|
+
? modeRaw
|
|
432
|
+
: fallback.mode;
|
|
433
|
+
const allowlist = asStringArray(raw['allowlist']) ?? [...fallback.allowlist];
|
|
434
|
+
const always_approve = asStringArray(raw['always_approve']) ?? [...fallback.always_approve];
|
|
435
|
+
const denylist = asStringArray(raw['denylist']) ?? [...fallback.denylist];
|
|
436
|
+
const timeout_s = asNumber(raw['timeout_s']) ?? fallback.timeout_s;
|
|
437
|
+
return { mode, allowlist, always_approve, denylist, timeout_s };
|
|
438
|
+
}
|
|
439
|
+
// ---------------------------------------------------------------------------
|
|
440
|
+
// Settings.load()
|
|
441
|
+
// ---------------------------------------------------------------------------
|
|
442
|
+
/**
|
|
443
|
+
* Load settings from `config.toml` (or `config.json`).
|
|
444
|
+
*
|
|
445
|
+
* - If the config file doesn't exist → write defaults and return them.
|
|
446
|
+
* - Applies the `EDGE_OWNER_OPERATOR_ID` env override (env wins over config).
|
|
447
|
+
* - If `machine_id` is empty → generate a UUIDv4, persist, and save.
|
|
448
|
+
*
|
|
449
|
+
* Mirrors Rust `Settings::load()`.
|
|
450
|
+
*/
|
|
451
|
+
export function loadSettings() {
|
|
452
|
+
const cfgPath = configPath();
|
|
453
|
+
if (!fs.existsSync(cfgPath)) {
|
|
454
|
+
// Also check for config.json fallback
|
|
455
|
+
const jsonPath = path.join(configDir(), 'config.json');
|
|
456
|
+
if (fs.existsSync(jsonPath)) {
|
|
457
|
+
const content = fs.readFileSync(jsonPath, 'utf8');
|
|
458
|
+
const settings = parseSettings(content);
|
|
459
|
+
applyEnvOverrides(settings);
|
|
460
|
+
ensureMachineId(settings);
|
|
461
|
+
return settings;
|
|
462
|
+
}
|
|
463
|
+
const defaults = defaultSettings();
|
|
464
|
+
defaults.machine_id = generateMachineId();
|
|
465
|
+
saveSettings(defaults);
|
|
466
|
+
return defaults;
|
|
467
|
+
}
|
|
468
|
+
const content = fs.readFileSync(cfgPath, 'utf8');
|
|
469
|
+
const settings = parseSettings(content);
|
|
470
|
+
applyEnvOverrides(settings);
|
|
471
|
+
ensureMachineId(settings);
|
|
472
|
+
return settings;
|
|
473
|
+
}
|
|
474
|
+
/** Apply `EDGE_OWNER_OPERATOR_ID` env override (env wins over config). */
|
|
475
|
+
function applyEnvOverrides(settings) {
|
|
476
|
+
const envOwner = process.env.EDGE_OWNER_OPERATOR_ID;
|
|
477
|
+
if (envOwner !== undefined) {
|
|
478
|
+
const trimmed = envOwner.trim();
|
|
479
|
+
if (trimmed.length > 0) {
|
|
480
|
+
settings.owner_operator_id = trimmed;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
/** Ensure machine_id is non-empty; generate + persist if missing. */
|
|
485
|
+
function ensureMachineId(settings) {
|
|
486
|
+
if (settings.machine_id.trim().length === 0) {
|
|
487
|
+
settings.machine_id = generateMachineId();
|
|
488
|
+
saveSettings(settings);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
// ---------------------------------------------------------------------------
|
|
492
|
+
// Resource limit helpers (mirrors ResourcesConfig methods)
|
|
493
|
+
// ---------------------------------------------------------------------------
|
|
494
|
+
/** True if any resource limit is set (non-zero). */
|
|
495
|
+
export function hasResourceLimits(config) {
|
|
496
|
+
return config.memory_max_mb > 0 || config.cpu_quota_percent > 0 || config.tasks_max > 0;
|
|
497
|
+
}
|
|
498
|
+
/** systemd `MemoryMax=` directive value, or undefined if unlimited. */
|
|
499
|
+
export function memoryMaxDirective(config) {
|
|
500
|
+
return config.memory_max_mb > 0 ? `${config.memory_max_mb}M` : undefined;
|
|
501
|
+
}
|
|
502
|
+
/** systemd `CPUQuota=` directive value, or undefined if unlimited. */
|
|
503
|
+
export function cpuQuotaDirective(config) {
|
|
504
|
+
return config.cpu_quota_percent > 0 ? `${config.cpu_quota_percent}%` : undefined;
|
|
505
|
+
}
|
|
506
|
+
/** systemd `TasksMax=` directive value, or undefined if unlimited. */
|
|
507
|
+
export function tasksMaxDirective(config) {
|
|
508
|
+
return config.tasks_max > 0 ? String(config.tasks_max) : undefined;
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Resolve resource limits from env overrides. Env wins over config.
|
|
512
|
+
* Env names: `EDGE_MEMORY_MAX_MB`, `EDGE_CPU_QUOTA_PERCENT`, `EDGE_TASKS_MAX`.
|
|
513
|
+
*/
|
|
514
|
+
export function resolveResources(env, config) {
|
|
515
|
+
function parseEnv(key, fallback) {
|
|
516
|
+
const raw = env[key];
|
|
517
|
+
if (raw === undefined)
|
|
518
|
+
return fallback;
|
|
519
|
+
const parsed = Number.parseInt(raw.trim(), 10);
|
|
520
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
521
|
+
}
|
|
522
|
+
return {
|
|
523
|
+
memory_max_mb: parseEnv('EDGE_MEMORY_MAX_MB', config.memory_max_mb),
|
|
524
|
+
cpu_quota_percent: parseEnv('EDGE_CPU_QUOTA_PERCENT', config.cpu_quota_percent),
|
|
525
|
+
tasks_max: parseEnv('EDGE_TASKS_MAX', config.tasks_max),
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
// ---------------------------------------------------------------------------
|
|
529
|
+
// Buffer encryption key (mirrors load_buffer_key + load_keyring)
|
|
530
|
+
// ---------------------------------------------------------------------------
|
|
531
|
+
/** Decode a hex-encoded 32-byte key. Throws on invalid hex or wrong length. */
|
|
532
|
+
export function decodeHexKey(hex) {
|
|
533
|
+
const trimmed = hex.trim();
|
|
534
|
+
const bytes = Buffer.from(trimmed, 'hex');
|
|
535
|
+
if (bytes.length !== 32) {
|
|
536
|
+
throw new Error(`Buffer key must be 32 bytes (64 hex chars), got ${bytes.length} bytes`);
|
|
537
|
+
}
|
|
538
|
+
return new Uint8Array(bytes);
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* Resolve the buffer encryption key.
|
|
542
|
+
* Precedence: `EDGE_BUFFER_KEY` env > `config.toml` `buffer.key`.
|
|
543
|
+
* Throws if unconfigured (fail-closed).
|
|
544
|
+
*/
|
|
545
|
+
export function loadBufferKey(settings) {
|
|
546
|
+
const envHex = process.env.EDGE_BUFFER_KEY;
|
|
547
|
+
if (envHex !== undefined && envHex.trim().length > 0) {
|
|
548
|
+
return decodeHexKey(envHex);
|
|
549
|
+
}
|
|
550
|
+
if (settings.buffer.key !== undefined && settings.buffer.key.length > 0) {
|
|
551
|
+
return decodeHexKey(settings.buffer.key);
|
|
552
|
+
}
|
|
553
|
+
throw new Error('Buffer encryption key not configured. Set EDGE_BUFFER_KEY (hex-encoded 32 bytes / 64 hex chars) ' +
|
|
554
|
+
'or buffer.key in config.toml.');
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* Resolve the buffer-encryption keyring (A4 key rotation).
|
|
558
|
+
* `current` follows the same precedence as `loadBufferKey`.
|
|
559
|
+
* `previous` is `EDGE_BUFFER_KEY_PREVIOUS` env (optional, rotation window only).
|
|
560
|
+
*/
|
|
561
|
+
export function loadKeyring(settings) {
|
|
562
|
+
const current = loadBufferKey(settings);
|
|
563
|
+
let previous;
|
|
564
|
+
const prevHex = process.env.EDGE_BUFFER_KEY_PREVIOUS;
|
|
565
|
+
if (prevHex !== undefined && prevHex.trim().length > 0) {
|
|
566
|
+
previous = decodeHexKey(prevHex);
|
|
567
|
+
}
|
|
568
|
+
return { current, ...(previous !== undefined ? { previous } : {}) };
|
|
569
|
+
}
|
|
570
|
+
// ---------------------------------------------------------------------------
|
|
571
|
+
// Edge auth token (mirrors resolve_edge_auth_token + load_edge_auth_token)
|
|
572
|
+
// ---------------------------------------------------------------------------
|
|
573
|
+
/**
|
|
574
|
+
* Pure precedence resolver for the edge-auth token. Env wins; whitespace is
|
|
575
|
+
* trimmed and empty values treated as unset.
|
|
576
|
+
*/
|
|
577
|
+
export function resolveEdgeAuthToken(envValue, configValue) {
|
|
578
|
+
if (envValue !== undefined) {
|
|
579
|
+
const t = envValue.trim();
|
|
580
|
+
if (t.length > 0) {
|
|
581
|
+
return t;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
if (configValue !== undefined) {
|
|
585
|
+
const t = configValue.trim();
|
|
586
|
+
if (t.length > 0) {
|
|
587
|
+
return t;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
return undefined;
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* Resolve the edge-auth shared secret.
|
|
594
|
+
* Precedence: `EDGE_AUTH_TOKEN` env → `config.toml` `[relay] auth_token`.
|
|
595
|
+
* Returns `undefined` when unconfigured.
|
|
596
|
+
*/
|
|
597
|
+
export function loadEdgeAuthToken(settings) {
|
|
598
|
+
return resolveEdgeAuthToken(process.env.EDGE_AUTH_TOKEN, settings.relay.auth_token);
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* Edge-side mirror of the relay's auth-escape hatch.
|
|
602
|
+
* Active iff `AUTH_DISABLED=1` OR `ALLOW_AUTH_BYPASS=1`.
|
|
603
|
+
*/
|
|
604
|
+
export function edgeAuthEscapeHatchActive() {
|
|
605
|
+
return process.env.AUTH_DISABLED === '1' || process.env.ALLOW_AUTH_BYPASS === '1';
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* Refuse to start with an empty `machine_id` when there is no manual token.
|
|
609
|
+
* Mirrors `enforce_hostname_or_token`.
|
|
610
|
+
*
|
|
611
|
+
* Returns `undefined` on success or an error `string` on refusal.
|
|
612
|
+
*/
|
|
613
|
+
export function enforceHostnameOrToken(machineId) {
|
|
614
|
+
const trimmed = machineId.trim();
|
|
615
|
+
if (trimmed.length > 0) {
|
|
616
|
+
return undefined;
|
|
617
|
+
}
|
|
618
|
+
// No machine_id — check whether the operator supplied a manual token.
|
|
619
|
+
const token = process.env.EDGE_AUTH_TOKEN;
|
|
620
|
+
if (token !== undefined && token.trim().length > 0) {
|
|
621
|
+
return undefined;
|
|
622
|
+
}
|
|
623
|
+
return ('Refusing to start: machine_id is empty AND EDGE_AUTH_TOKEN is unset. ' +
|
|
624
|
+
'On macOS this typically means `hostname` returned a non-RFC1123 value. ' +
|
|
625
|
+
'Set machine_id in config.toml ([machine_id] section) or provide EDGE_AUTH_TOKEN ' +
|
|
626
|
+
'explicitly via the environment to override.');
|
|
627
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AES-256-GCM encrypt/decrypt matching the Rust `DiskDumper` envelope format.
|
|
3
|
+
*
|
|
4
|
+
* On-disk format (mirrors `edge-client/src/buffer/disk_dump.rs`):
|
|
5
|
+
* ```
|
|
6
|
+
* [KEY_VERSION: 1 byte][nonce: 12 bytes][ciphertext + GCM auth tag: 16 bytes appended]
|
|
7
|
+
* ```
|
|
8
|
+
*
|
|
9
|
+
* The Rust `aes-gcm` crate appends the 16-byte GCM tag to the ciphertext.
|
|
10
|
+
* Node's `crypto.createCipheriv('aes-256-gcm')` returns the tag separately via
|
|
11
|
+
* `getAuthTag()`, so we manually append it to match the Rust byte layout.
|
|
12
|
+
*/
|
|
13
|
+
/** On-disk encrypted-blob format version (first byte of every dump file). */
|
|
14
|
+
export declare const KEY_VERSION = 1;
|
|
15
|
+
/** AES-256-GCM nonce length (bytes). */
|
|
16
|
+
export declare const NONCE_LEN = 12;
|
|
17
|
+
/** AES-256-GCM authentication tag length (bytes, appended to ciphertext). */
|
|
18
|
+
export declare const GCM_TAG_LEN = 16;
|
|
19
|
+
/** Minimum blob size: version(1) + nonce(12) + tag(16). */
|
|
20
|
+
export declare const MIN_BLOB_LEN: number;
|
|
21
|
+
/**
|
|
22
|
+
* Encrypt `plaintext` with a 32-byte `key` using AES-256-GCM.
|
|
23
|
+
*
|
|
24
|
+
* Returns a Buffer in the DiskDumper format:
|
|
25
|
+
* `[KEY_VERSION][nonce:12][ciphertext][tag:16]`.
|
|
26
|
+
*/
|
|
27
|
+
export declare function encrypt(key: Uint8Array, plaintext: string): Buffer;
|
|
28
|
+
/** Result of {@link decrypt}: plaintext + whether the previous key was used. */
|
|
29
|
+
export interface DecryptResult {
|
|
30
|
+
readonly plaintext: string;
|
|
31
|
+
/** `true` if the blob was decrypted with `previousKey` (pending rotation). */
|
|
32
|
+
readonly usedPrevious: boolean;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Decrypt a DiskDumper-format blob. Tries `currentKey` first, then
|
|
36
|
+
* `previousKey` (rotation window). Throws on malformed data or auth failure.
|
|
37
|
+
*/
|
|
38
|
+
export declare function decrypt(data: Buffer, currentKey: Uint8Array, previousKey?: Uint8Array): DecryptResult;
|